5. Defensive patterns for every method

This page makes Chapter 4's Make → Check → Extract pattern universal: the same three steps drive POST, PUT, and DELETE, with small method-specific variations in what you validate and what you extract. By the end you'll have a single BlogPostManager class that demonstrates all four CRUD methods sharing one defensive shape, and the vocabulary to apply that shape to any HTTP method any future API throws at you.

The universal pattern

Make → Check → Extract applies to every HTTP method. The difference is in what each step does for each verb:

Make → Check → Extract by HTTP method
Step GET POST PUT DELETE
1. Make Send with timeout Validate input, send with timeout Validate input, send with timeout Validate input, send with timeout
2. Check 200; Content-Type 200/201 success; 400, 409, 401/403 errors 200/204 success; 404, 409, 403, 400 errors 200/204 success; 404 = success; 409, 403, 423 errors
3. Extract Parse JSON, validate structure Parse JSON response Parse JSON if 200, skip if 204 Usually no body to parse

The four validation layers from Chapter 4 carry over intact: the network layer wraps the request in try/except for Timeout, ConnectionError, and RequestException; the HTTP layer checks status codes (200/201/204 for success, 400/404/409 for method-specific failures); the format layer validates Content-Type before parsing; and the structure layer checks that response data has the shape the caller expects. You've seen these four layers in the POST, PUT, and DELETE examples already, the rest of this page makes them reusable.

A complete CRUD example

Before reading the class, notice something about this chapter so far. You have now written the same try/except network wrapper and the same JSON-parsing check several times, once per method page. That repetition was useful while you were learning the shape. Inside a class, it becomes duplication, and duplication is where a helper method earns its keep.

So BlogPostManager factors out exactly two things: the network layer (one _make_request helper holds the try/except that every method was repeating) and the parse step (one _parse_json helper holds the Content-Type check and the invalid-JSON guard). What it deliberately does not factor out is the status-code handling. Each method keeps its own visible branches, because 409 means "duplicate" to POST, "edit conflict" to PUT, and "dependencies exist" to DELETE, and that difference is the content of this chapter. Shared mechanics get extracted; method-specific semantics stay where you can read them.

One new piece of syntax makes the sharing possible: requests.request(method, url, ...) is the generic form behind requests.get, requests.post, and the rest. Passing the verb as a string is what lets four methods use one wrapper.

To see all four methods working together, here's a blog post manager with full defensive handling. Save this as blog_manager.py:

blog_manager.py
import requests

class BlogPostManager:
    """
    Demonstrates all HTTP methods with professional error handling.
    Applies Chapter 4's defensive patterns to all operations.
    Shared mechanics live in helpers; per-method status logic stays visible.
    """

    def __init__(self, base_url="https://httpbin.org"):
        self.base_url = base_url
        self.timeout = 10

    def _make_request(self, method, endpoint, **kwargs):
        """
        Network layer, factored out once.
        Every method makes requests the same way, so the try/except
        lives in exactly one place. Returns (response, error_message).
        """
        try:
            response = requests.request(
                method,
                f"{self.base_url}{endpoint}",
                timeout=self.timeout,
                **kwargs
            )
            return response, None
        except requests.exceptions.Timeout:
            return None, "Request timed out"
        except requests.exceptions.ConnectionError:
            return None, "Could not connect to server"
        except requests.exceptions.RequestException as e:
            return None, f"Network error: {e}"

    def _validate_content_type(self, response, expected="application/json"):
        """Validate response content type (Chapter 4 pattern)."""
        content_type = response.headers.get("Content-Type", "")
        if expected not in content_type:
            return False, f"Expected {expected} but received {content_type}"
        return True, ""

    def _parse_json(self, response):
        """
        Format and extract layers, factored out once.
        Returns (data, error_message).
        """
        valid, error = self._validate_content_type(response)
        if not valid:
            return None, error
        try:
            return response.json(), None
        except ValueError:
            return None, "Server returned invalid JSON"

    def create_post(self, title, content, author):
        """
        CREATE: Use POST to create a new blog post.
        POST's status semantics stay here, visible: 201 on success,
        409 for duplicates, 400 for invalid data.
        """

        # Validate input before making request
        if not all([title, content, author]):
            return (False, None, "Title, content, and author are required")

        post_data = {
            "title": title,
            "content": content,
            "author": author
        }

        response, error = self._make_request("POST", "/post", json=post_data)
        if error:
            return (False, None, error)

        # Check status codes specific to POST
        if response.status_code in (200, 201):
            data, error = self._parse_json(response)
            if error:
                return (False, None, error)
            return (True, data, f"Post '{title}' created successfully")

        elif response.status_code == 409:
            return (False, None, f"Post '{title}' already exists")

        elif response.status_code == 400:
            return (False, None, "Invalid post data")

        else:
            return (False, None, f"Creation failed: {response.status_code}")

    def read_post(self, post_id):
        """
        READ: Use GET to retrieve a blog post.
        GET's semantics: 200 with a body on success, 404 for missing.
        """
        if not post_id:
            return (False, None, "Post ID is required")

        response, error = self._make_request(
            "GET", "/get", params={"post_id": post_id}
        )
        if error:
            return (False, None, error)

        # Check status codes specific to GET
        if response.status_code == 200:
            data, error = self._parse_json(response)
            if error:
                return (False, None, error)

            # In real API, validate data has expected structure
            return (True, data, f"Retrieved post {post_id}")

        elif response.status_code == 404:
            return (False, None, f"Post {post_id} not found")

        else:
            return (False, None, f"Request failed: {response.status_code}")

    def update_post(self, post_id, title, content, author):
        """
        UPDATE: Use PUT to replace entire blog post.
        PUT's semantics: 200 with a body or 204 without one on success,
        404 for missing, 409 for edit conflicts, 403 for permissions.
        """
        if not post_id:
            return (False, None, "Post ID is required")

        if not all([title, content, author]):
            return (False, None, "All fields required for complete replacement")

        updated_data = {
            "post_id": post_id,
            "title": title,
            "content": content,
            "author": author
        }

        response, error = self._make_request("PUT", "/put", json=updated_data)
        if error:
            return (False, None, error)

        # Check status codes specific to PUT
        if response.status_code == 200:

            # Success with response data
            data, error = self._parse_json(response)
            if error:
                return (False, None, error)
            return (True, data, f"Post {post_id} updated successfully")

        elif response.status_code == 204:

            # Success without response content (common for PUT)
            return (True, None, f"Post {post_id} updated successfully")

        elif response.status_code == 404:
            return (False, None, f"Post {post_id} not found - cannot update")

        elif response.status_code == 409:
            return (False, None, "Post was modified by someone else - please reload")

        elif response.status_code == 403:
            return (False, None, f"Not authorised to update post {post_id}")

        else:
            return (False, None, f"Update failed: {response.status_code}")

    def delete_post(self, post_id):
        """
        DELETE: Remove a blog post.
        DELETE's semantics: 200/204 on success, 404 treated as success
        (the goal state is reached), 409 for dependencies, 403 for
        permissions. No response body to parse, so data is always None.
        """
        if not post_id:
            return (False, None, "Post ID is required")

        response, error = self._make_request(
            "DELETE", "/delete", json={"post_id": post_id}
        )
        if error:
            return (False, None, error)

        # Check status codes specific to DELETE
        if response.status_code in (200, 204):
            return (True, None, f"Post {post_id} deleted successfully")

        elif response.status_code == 404:

            # Treat as success - goal accomplished
            return (True, None, f"Post {post_id} not found (already deleted)")

        elif response.status_code == 409:
            return (False, None, f"Cannot delete post {post_id} - dependencies exist")

        elif response.status_code == 403:
            return (False, None, f"Not authorised to delete post {post_id}")

        else:
            return (False, None, f"Deletion failed: {response.status_code}")

# Demonstrate complete CRUD workflow
print("="*60)
print("PRODUCTION-GRADE CRUD DEMONSTRATION")
print("="*60)

blog = BlogPostManager()

# CREATE
print("\n1. CREATE - Making a new blog post")
success, data, msg = blog.create_post(
    "Learning HTTP Methods",
    "Today I learned about GET, POST, PUT, and DELETE...",
    "Alice"
)
print(f"   {'✅' if success else '❌'} {msg}")

# READ
print("\n2. READ - Retrieving the blog post")
success, data, msg = blog.read_post(123)
print(f"   {'✅' if success else '❌'} {msg}")

# UPDATE
print("\n3. UPDATE - Modifying the blog post")
success, data, msg = blog.update_post(
    123,
    "Mastering HTTP Methods",
    "I've now mastered GET, POST, PUT, and DELETE with defensive programming!",
    "Alice"
)
print(f"   {'✅' if success else '❌'} {msg}")

# DELETE
print("\n4. DELETE - Removing the blog post")
success, data, msg = blog.delete_post(123)
print(f"   {'✅' if success else '❌'} {msg}")

print("\n" + "="*60)
print("All operations completed with professional error handling!")
print("="*60)
Output
============================================================
PRODUCTION-GRADE CRUD DEMONSTRATION
============================================================

1. CREATE - Making a new blog post
   ✅ Post 'Learning HTTP Methods' created successfully

2. READ - Retrieving the blog post
   ✅ Retrieved post 123

3. UPDATE - Modifying the blog post
   ✅ Post 123 updated successfully

4. DELETE - Removing the blog post
   ✅ Post 123 deleted successfully

============================================================
All operations completed with professional error handling!
============================================================

That's still a substantial class, so it's worth naming the structure explicitly. Every method now reads the same way: validate input, call _make_request, walk the status-code branches, extract through _parse_json where there's a body. The network try/except lives once in _make_request; the Content-Type check and the invalid-JSON guard live once in _parse_json; the status ladders stay in their methods because they are the method-specific knowledge, with branches for 201, 204, 409, and 404 that mean different things per verb. Every method returns a (success, data, message) tuple the caller can pattern-match on (DELETE's data is always None, since the method has no body to extract, but the shape stays consistent). And every outcome produces a clear, actionable message.

This synthesis covers the most common branches per method. The per-method patterns earlier in the chapter include a few extra branches (PUT's 400 validation failures, DELETE's 423 locked) that BlogPostManager doesn't reproduce, because the goal here is the shared shape rather than full coverage. When you adapt BlogPostManager against a real API, copy the missing branches from the per-method pages.

Yes, it's verbose. The verbosity is what prevents production bugs and gives users messages they can act on. It's the gap between "works in the tutorial" and "survives real traffic." Every CRUD API you'll build in the rest of this book is a variation on this shape, and the next page recaps the patterns and tests them with a short quiz before Chapter 6 takes you into the realities of parsing the JSON these methods return.