Skip to main content
Learning Center
Python Programming

Module 12: Web Apps and Service Boundaries

Validating at the Boundary, and Errors That Help the Right People

Rejecting bad input at the edge with a message that names the problem, keeping internal detail out of the response, and understanding what CORS actually protects.

Lesson 38 of 46 in the recommended order · About 25 min (estimate)

On this page

Outcome

By the end of this lesson you can check an incoming parameter for presence, type, and range, return an error that names the problem without revealing anything internal, and say what CORS does and does not protect.

Why it matters

Everything arriving at a service boundary came from outside, which means all of it is text, some of it is wrong, and any of it may be hostile. Validating at the edge means the rest of your program can assume it is holding a number when it expects a number, which is the same argument Module 6 made about normalising a batch, applied to one request at a time.

Error responses have two audiences with opposite needs. A developer integrating with your service needs to know what they got wrong. Anyone probing it should learn as little as possible about how it works. Serving both is a matter of deciding, once, what belongs in the response and what belongs only in the log.

Concept

Validate in a fixed order, and stop at the first failure: presence, then type, then range, then meaning.

  • Presence. Required and missing is a 400 naming the parameter. Optional and missing takes a documented default.
  • Type. Everything from a query string is text. Convert deliberately; a failed conversion is a 400, not a crash.
  • Range. A page size of five thousand is well-formed and unreasonable. Decide between clamping it to your maximum and refusing it, and say which you did.
  • Meaning. A date window whose end is before its start is individually valid and jointly nonsense.

For range specifically, clamping is often kinder than refusing. A caller asking for more than you will give is not making an error, and refusing costs them a round trip. Say so in the response, "clamped": true, so the behaviour is honest rather than silent.

An error body should carry a stable machine-readable code and a short human-readable detail:

{"error": "invalid_parameter", "parameter": "limit", "detail": "must be a whole number"}

The code is what a client branches on, so it must not change wording between releases. The parameter name is what a person needs. Neither exposes anything internal.

What a production error response must not expose: a stack trace, a SQL statement, a file path, an internal hostname, unnecessary library-version detail, or a message distinguishing "no such user" from "wrong password". Diagnostic detail belongs in access-controlled logs, attached to a request identifier that also appears in the response. That way support can find the relevant event without publishing internals to the caller.

Status codes for this stage: 400 for malformed input, 401 for missing or invalid credentials, 403 for authenticated but not permitted, 404 for not found, 422 where a service distinguishes semantically invalid from malformed, 429 for rate limiting, and 500 for a fault on your side. A 500 should never contain detail; it should contain a request identifier.

CORS, briefly, because it is widely misunderstood. It is a browser rule about which web pages may read a response from another origin. It is an important browser isolation boundary, but it is not authentication or general API access control: it does not stop a command-line client or another server from calling your endpoint. Authentication and authorisation decide who may receive protected data; CORS decides whether a browser exposes a cross-origin response to page script.

Read the code

MAX_LIMIT = 100
DEFAULT_LIMIT = 25


def parse_query(query, request_id="req-001"):
    """Return (status, body). Validates presence, type, and range."""
    state = query.get("state")
    if not state:
        return 400, {"error": "missing_parameter", "parameter": "state", "request_id": request_id}

    raw_limit = query.get("limit")
    if raw_limit is None:
        limit, clamped = DEFAULT_LIMIT, False
    else:
        try:
            limit = int(raw_limit)
        except (TypeError, ValueError):
            return 400, {
                "error": "invalid_parameter",
                "parameter": "limit",
                "detail": "must be a whole number",
                "request_id": request_id,
            }
        clamped = limit > MAX_LIMIT
        limit = min(max(limit, 1), MAX_LIMIT)

    return 200, {"state": state.upper(), "limit": limit, "clamped": clamped}


print(parse_query({"state": "or"}))
print(parse_query({"state": "OR", "limit": "5000"}))
print(parse_query({"state": "OR", "limit": "many"}))
print(parse_query({"limit": "10"}))

The order is the order from the Concept section, and each check returns immediately, so a caller is told about one problem at a time rather than being handed a list they must decode.

except (TypeError, ValueError) catches both a non-numeric string and a None that slipped through, and nothing else. This is Module 5's narrow-catch rule at the request boundary.

min(max(limit, 1), MAX_LIMIT) clamps at both ends, so limit=0 and limit=-5 become 1 rather than producing an empty page or a database error. clamped records only the upper-bound case, which is the one a caller asked for and did not get.

state.upper() normalises on the way in, so nothing downstream has to wonder whether it received "or" or "OR". That is Module 6's normalisation, applied at the boundary rather than deep inside.

Predict the output

Predict the four printed lines.

Check your prediction
(200, {'state': 'OR', 'limit': 25, 'clamped': False})
(200, {'state': 'OR', 'limit': 100, 'clamped': True})
(400, {'error': 'invalid_parameter', 'parameter': 'limit', 'detail': 'must be a whole number', 'request_id': 'req-001'})
(400, {'error': 'missing_parameter', 'parameter': 'state', 'request_id': 'req-001'})

Line 1 shows the default applying and the state normalised to upper case. Line 2 shows the clamp with clamped reporting it, so the caller knows they did not get the page size they asked for.

Line 4 is a 400 even though limit was perfectly valid, because presence is checked first and the function returns at the first failure.

Modify the code

Change the clamping line to limit = min(limit, MAX_LIMIT), dropping the lower bound. Predict what parse_query({"state": "OR", "limit": "0"}) returns and what happens downstream.

What changes, and why

It returns (200, {'state': 'OR', 'limit': 0, 'clamped': False}).

Nothing fails here, and it fails later. A page size of zero produces an empty result set that looks exactly like "no matches", and a caller paginating through results gets an empty page and stops. A negative value is worse: in SQL, LIMIT -1 means no limit at all in SQLite, so a request for a negative page size can return the entire table.

That is the argument for clamping at both ends. The upper bound protects your service from a large request; the lower bound protects the caller from a nonsensical one, and protects you from a value that some component further down interprets in a way you did not intend.

Debug the bug

An assistant was asked to improve the API's error messages so integration is easier. It produced this.

def handle(query, connection):
    try:
        limit = int(query["limit"])
        rows = connection.execute(
            "SELECT notice_id FROM notices WHERE state = ? LIMIT ?",
            (query["state"], limit),
        ).fetchall()
        return 200, {"results": rows}
    except Exception as error:
        return 500, {
            "error": str(error),
            "query": query,
            "sql": "SELECT notice_id FROM notices WHERE state = ? LIMIT ?",
            "db_path": "/srv/review/data/opportunities.sqlite3",
        }
What's actually wrong

The error body is a description of the system, returned to whoever asked.

str(error) is the raw exception text, which for a database error names tables and columns. query echoes back whatever the caller sent, which turns the endpoint into a small reflector. sql publishes the statement shape. db_path gives away the filesystem layout and the deployment's directory structure. Someone probing the service learns more from a malformed request than from the documentation.

The status is wrong too. A missing limit key raises KeyError, which is entirely the caller's fault, and is reported as 500, telling them the server is broken when their request was.

And the catch is bare in the sense that matters: except Exception around the whole body means a genuine internal fault, a caller error, and a programming mistake all produce the same response.

The corrected shape validates first, then handles narrowly, and logs what it does not say:

def handle(query, connection, request_id):
    status, parsed = parse_query(query, request_id)
    if status != 200:
        return status, parsed
    try:
        rows = connection.execute(
            "SELECT notice_id FROM notices WHERE state = ? LIMIT ?",
            (parsed["state"], parsed["limit"]),
        ).fetchall()
    except sqlite3.DatabaseError:
        logging.exception("query failed request_id=%s", request_id)
        return 500, {"error": "internal_error", "request_id": request_id}
    return 200, {"results": rows}

The caller gets a code and a request identifier. The full detail goes to the log, where support can find it. That is the split: everything useful is recorded, and nothing internal is published.

Try it yourself

Validate three query-parameter sets: one valid, one asking for more than the service will give, and one missing a required parameter.

Loading this exercise…

Practical challenge (optional)

Optional: add a date window, posted_from and posted_to, validate each as an ISO date, and add a meaning check rejecting a window whose end precedes its start. Give that failure its own error code, distinct from a malformed date. Then write one sentence on why the two deserve different codes. A caller who sent two valid dates in the wrong order needs a different fix from one who sent nonsense.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. In what order should a boundary validate an input, and why stop at the first failure?
  2. Why include a stable machine-readable code as well as a human-readable detail?
  3. Name four things that must never appear in an error response body.
  4. What does CORS protect, and what does it not?
Answers
  1. Presence, type, range, then meaning. Returning at the first failure keeps each response about one problem, which is easier for a caller to act on than a decoded list.
  2. Clients branch on the code, so it must not change wording between releases; people read the detail. Serving both without either changing under the other is why they are separate fields.
  3. A stack trace, a SQL statement, a file path, an internal hostname, a library version, or a message distinguishing "no such user" from "wrong password". Any four of those.
  4. It is a browser rule about which pages may read a response from another origin. It does not stop a script, a command-line client, or a server from calling the endpoint, and it never protects data; authentication and authorisation do.

Sign in to track your progress on this exercise.

Summary and next step

Validate presence, type, range, and meaning in that order; clamp where refusing would be unkind and say that you did; return a stable code plus a request identifier and keep everything else in the log; and remember CORS is a browser rule, not access control. Next: putting these together into a small read-only local interface over the shortlist.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.