Make the Endpoint Reject Bad Input

The working endpoint accepted a request with annual_inc sent as the string "forty thousand". The value flowed straight into the model, which raised a cryptic error from deep inside scikit-learn that read like the server itself had broken. That was the small problem. The larger one was structural: the request schema I had shipped accepted every column from the training data, including a whole family of post-outcome fields like recoveries, total_pymnt, and last_pymnt_d that only get populated during or after the loan’s life, long after the origination moment the model is supposed to predict at. The endpoint worked. It was one malformed record and a dozen leakage fields away from serving silent garbage.

In the last lesson joblib.load() moved out of the request handler and up to module level, so the model loads once at startup instead of being re-read from disk on every call. That fixed where the model loads. It did nothing about what the handler feeds the model. The handler from Lesson 1 still takes whatever JSON arrives and hands it straight to model.predict. This lesson hardens that boundary. The plan is three passes, each closing one gap the working endpoint left open: reject malformed input with a clear error before the model ever runs, exclude every leakage feature from the request contract so a column that does not exist at prediction time can never reach the model, and return a structured, versioned response that other teams can build on without breaking when the model changes.

The working endpoint accepted a request with annual_inc sent as the string "forty thousand". The value flowed straight into the model, which raised a cryptic error from deep inside scikit-learn that read like the server itself had broken. That was the small problem. The larger one was structural: the request schema I had shipped accepted every column from the training data, including a whole family of post-outcome fields like recoveries, total_pymnt, and last_pymnt_d that only get populated during or after the loan’s life, long after the origination moment the model is supposed to predict at. The endpoint worked. It was one malformed record and a dozen leakage fields away from serving silent garbage.

In the last lesson joblib.load() moved out of the request handler and up to module level, so the model loads once at startup instead of being re-read from disk on every call. That fixed where the model loads. It did nothing about what the handler feeds the model. The handler from Lesson 1 still takes whatever JSON arrives and hands it straight to model.predict. This lesson hardens that boundary. The plan is three passes, each closing one gap the working endpoint left open: reject malformed input with a clear error before the model ever runs, exclude every leakage feature from the request contract so a column that does not exist at prediction time can never reach the model, and return a structured, versioned response that other teams can build on without breaking when the model changes.

Validate at the boundary, fail before the model

The plausible-but-wrong model here is that the handler can trust its caller. The caller is another service the same team owns, or a scheduled job written in-house, so the data arriving at /predict is presumed well-formed and the handler passes it through. That assumption holds in testing, where every request is hand-constructed, and it fails the first time a real caller fat-fingers a field. The validation it skipped does not disappear; it moves downstream, into model.predict, where the error is far more expensive to read.

Watch the wrong type pass straight through an unvalidated handler. The record below carries annual_inc as text instead of a number, and the handler does what the Lesson 1 handler did, assembling a feature array and calling the model:

import numpy as np

# The handler trusts the caller and feeds the raw dict to the model.
def predict_unvalidated(record: dict) -> dict:
    features = np.array([[record["annual_inc"], record["loan_amnt"]]])
    proba = model.predict_proba(features)[0, 1]  # raises deep inside sklearn
    return {"probability": float(proba)}

bad = {"annual_inc": "forty thousand", "loan_amnt": 10000}
predict_unvalidated(bad)
# ValueError: could not convert string to float: 'forty thousand'
#   ...raised three frames deep inside numpy/sklearn, alerting as a 500

What surfaces is a 500 Internal Server Error carrying a stack trace that points at scikit-learn. That is a named failure mode: the misattributed-500. The status code says the server is broken, the on-call engineer reads the traceback as a model bug, and the first twenty minutes go to the wrong layer, because nothing in the response said “the caller sent garbage.” The root cause is a 400-level client error (bad input) that surfaced as a 500-level server error (the server failed), and the boundary that should have caught it (the network edge, before any prediction logic) was never there. Without a schema the body arrives as an untyped dict: a missing key becomes a KeyError in feature assembly, a string where a number was expected becomes a ValueError inside predict, a wrong shape becomes a NumPy broadcasting error. Each of these is a different exception from a different depth, and all of them read as “the server is broken.”

Validation belongs at the boundary instead, and a pydantic request schema is the M1 data contract enforced at the network edge. A pydantic model performs all parsing and validation at construction, the moment the object is instantiated, before a single line of handler logic runs. FastAPI uses this directly: when a pydantic model is declared as the request body, FastAPI reads the body as JSON, coerces the types, and validates the data before the path operation function is ever called. Valid data is the only thing that reaches the handler. Invalid data is converted, at the door, into one clear early error instead of a deep one. The interior code can then assume well-formed input, which is the whole point of a boundary: it lets everything behind it stop checking.

Here is the same record run through a pydantic schema. Watch where the failure lands, not three frames deep, but at the moment of construction, naming the field:

python
from pydantic import BaseModel, ValidationError


class LoanRecord(BaseModel):
    annual_inc: float
    loan_amnt: float
    grade: str


good = {"annual_inc": 40000, "loan_amnt": 10000, "grade": "B"}
record = LoanRecord(**good)
print("parsed:", record.annual_inc, type(record.annual_inc).__name__)

bad = {"annual_inc": "forty thousand", "loan_amnt": 10000, "grade": "B"}
try:
    LoanRecord(**bad)
except ValidationError as exc:
    err = exc.errors()[0]
    print("rejected field:", err["loc"], "->", err["msg"])

Notice the good record parses, and annual_inc comes back as a float even though it was passed as an int: pydantic coerced the type. The bad record raises a ValidationError at construction, and the error names exactly where (loc, the field path) and what (msg, the expected type) was wrong. When this schema is the declared request body, FastAPI catches that same ValidationError and turns it into an HTTP 422 Unprocessable Entity whose body is the list of field errors, a response the caller can act on programmatically rather than a stack trace to decode. The non-obvious cost is that coercion is doing real work here: pydantic accepted the integer 40000 for a float field silently, which is convenient, but the same leniency will accept "40000" (a numeric string) as a float by default. The boundary rejects "forty thousand" and admits "40000", so “validated” does not mean “exactly the declared type”; it means “coercible to it.” When a downstream calculation depends on a field being genuinely numeric and never a stringified number, the field is constrained explicitly rather than left to the default coercion.

Why does the unvalidated path raise a ValueError from inside scikit-learn rather than at the handler’s first line? What is the latest possible moment Python could have caught "forty thousand", and why is “latest possible” the wrong place for a serving boundary?


Try It 1

The schema below annotates one field with the wrong type — age: str — so a value like "thirty" sails through as a perfectly valid string and a number sent as text is never caught. The context is a Random User API profile (a free API that returns fake person records: name, age, email) rather than a loan, transferring the boundary-validation idea to a new shape. Change the annotation to the correct numeric type so age sent as text is rejected at construction time.

python
from pydantic import BaseModel, ValidationError


class UserProfile(BaseModel):
    name: str
    age: str  # wrong type -- a number sent as text passes through unvalidated
    email: str


bad = {"name": "Ada", "age": "thirty", "email": "ada@example.com"}
try:
    UserProfile(**bad)
    print("accepted (should have been rejected)")
except ValidationError as exc:
    print("rejected:", exc.errors()[0]["loc"])
Hint A pydantic field validates against the type its annotation declares, so an `age` annotated `str` happily accepts the text `"thirty"` — it *is* a valid string. The field is only checked against the type you name, so naming the wrong type is as good as no check for the value you care about. Re-read the paragraph on validation at construction — what type does `age` need so that a non-numeric string is rejected rather than admitted?

Solution

Change the field’s annotation from str to its real numeric type so pydantic checks the value against the type you actually mean. Watch the text age get rejected at construction with the field named, the same boundary failure the loan schema produced.

python
from pydantic import BaseModel, ValidationError


class UserProfile(BaseModel):
    name: str
    age: int  # typed field -- pydantic now validates and coerces it
    email: str


bad = {"name": "Ada", "age": "thirty", "email": "ada@example.com"}
try:
    UserProfile(**bad)
    print("accepted (should have been rejected)")
except ValidationError as exc:
    err = exc.errors()[0]
    print("rejected:", err["loc"], "->", err["msg"])

Annotating age: int turns it into a validated field, so "thirty" is rejected at construction with the field named, the same 422-shaped failure the loan schema produced. An unannotated assignment is read as a default value, not a contract, which is the kind of gap a reviewer who treats the schema as documentation rather than enforcement will leave in.

The request schema is where you exclude the leakage features

A reasonable engineer ships the request schema by reading the columns off the training DataFrame. The model was trained on those columns, so the schema accepts those columns: the request shape mirrors the training shape, and that feels like the safe, complete answer. It is the answer that reopens the leakage trap from M3 at the serving layer, because the training shape contains fields that do not exist at the moment of scoring.

Data leakage is a train/serve mismatch in what information is available when: a feature carries label information that is not available at the instant of inference. Lending Club’s training data carries a family of post-outcome columns (recoveries, collection_recovery_fee, total_pymnt, total_rec_prncp, last_pymnt_d, last_pymnt_amnt, out_prncp and more) that are populated only during or after the loan’s life. Offline, every row has them, because the loans already ran their course. Online, at the moment a loan application must be scored, the loan has not been issued yet, so every one of those columns is unknowable. The killer is recoveries: a loan recovers money only after the borrower defaults, so the column is nonzero almost exclusively for defaulted loans. It is a near-perfect proxy for the label. A model trained with it leans on it almost entirely and scores trivially well offline, then collapses at serve time, because the caller either omits the column (the model has nothing to lean on) or sends a placeholder zero (every applicant scores as fully paid).

Dropping the leakage features before training is the standard advice, and it is necessary and not sufficient. The gap is the named failure mode here: the leaky-contract reopen. The model gets retrained without recoveries, the team congratulates itself, and the request schema still lists it, so a caller can still send it, a future handler can still forward it, and the next person who rebuilds the feature pipeline from the schema puts it back. Offline AUC looks excellent, live precision is dismal, and the investigation traces it to a column the model “was not trained on” but the API still advertised. The schema, not the model code, is the control point, because the schema is the explicit, reviewable list of what the model is allowed to consume. FastAPI enforces it structurally: if a field is not declared in the request model, no caller can supply it and no handler can forward it. Leakage exclusion stops being a discipline the caller has to remember and becomes a property the contract guarantees.

What makes this enforceable rather than aspirational is that it becomes machine-checkable. BaseModel.model_fields exposes the declared field names as a mapping, so the exclusion can be asserted in code and run in CI. The schema below is the leakage-hardened evolution of the LoanRecord contract from Lesson 1, renamed ServingRequest to mark that it now encodes a serving-time guarantee rather than a raw column list; it declares only origination-time fields, and the assertion proves no leakage field crossed into it:

python
from pydantic import BaseModel

# Columns that exist only during or after the loan's life -- unknowable at scoring.
LEAKAGE_FIELDS = {
    "recoveries",
    "collection_recovery_fee",
    "total_pymnt",
    "total_rec_prncp",
    "last_pymnt_d",
    "out_prncp",
}


class LeakyRequest(BaseModel):
    annual_inc: float
    loan_amnt: float
    grade: str
    recoveries: float  # post-outcome -- reaches across the scoring line
    total_pymnt: float  # post-outcome


class ServingRequest(BaseModel):
    annual_inc: float
    loan_amnt: float
    grade: str
    emp_length: float  # all known at origination


leaky_overlap = LEAKAGE_FIELDS & LeakyRequest.model_fields.keys()
serving_overlap = LEAKAGE_FIELDS & ServingRequest.model_fields.keys()
print("LeakyRequest leakage fields:", sorted(leaky_overlap))
print("ServingRequest leakage fields:", sorted(serving_overlap))
assert not serving_overlap, "leakage field reached the request schema"
print("ServingRequest is clean")

The assertion assert not (LEAKAGE_FIELDS & ServingRequest.model_fields.keys()) is exactly what a CI test runs on every commit: it fails the build the moment anyone adds a post-outcome column back to the request contract. The print shows LeakyRequest advertising recoveries and total_pymnt while ServingRequest carries none of them. The discipline this enforces is the real lesson, and it does not reduce to memorizing one banned name. The audit is per-column and runs in one direction: for every field, ask “is this known at origination, the instant of scoring?” The dozen that fail that test come out, not because they appear on a blocklist, but because the question has one answer for each of them.

How early in the loan’s life does each column actually appear? The scrolly below makes the temporal mismatch visible. The vertical line is the scoring instant, origination, and each column lights up only at the point in the loan’s life when it actually becomes known. Everything that lights up to the right of the line is the future the model is trying to predict, and any schema field that reaches across that line is leakage by construction.

Origination — the scoring instant. The loan application arrives and the model must score it now. Only application-time fields are populated: annual_inc, loan_amnt, grade, emp_length. This is the vertical score line; everything to its right has not happened yet.

The loan is issued, first payments arrive. Now total_pymnt, last_pymnt_d, and last_pymnt_amnt start to fill in, but every one of them lights up after the score line. At scoring time these were blank, so any model that learned to read them has nothing to read online.

The loan runs its term. out_prncp, total_rec_prncp, and total_rec_int fill in as principal and interest are repaid over months. These describe how the loan performed, which is a fact about the outcome, not the application.

Default and collections. recoveries, collection_recovery_fee, and settlement_amount light up, and these are the near-perfect label proxies, because they are nonzero almost only when the borrower defaulted. A model handed recoveries is being handed a thinly disguised copy of the answer.

Overlay the two schemas. ServingRequest contains only the fields left of the score line. LeakyRequest reaches across it into the future. The request schema is the one place this reach becomes structurally impossible: a field that is not declared cannot be sent.

The timeline shows why “drop it from the model” and “drop it from the schema” are two different controls. The model retrains; the schema is the contract a reviewer reads and a CI test asserts against. Now apply the audit to a dataset that is not loans.


Try It 2

This task scores churn for a subscription service at signup, predicting, the day a member joins, whether they will cancel within a year. The candidate fields are below. Identify which ones would only be known after the predicted event, put them in POST_EVENT_FIELDS, and build a SignupRequest schema that omits all of them. The assertion must pass.

python
from pydantic import BaseModel

candidate_fields = [
    "plan_tier",  # chosen at signup
    "signup_channel",  # known at signup
    "total_months_active",  # ??? known only after the subscription runs
    "cancellation_reason",  # ??? only exists if they cancelled
    "lifetime_revenue",  # ??? accrues over the subscription's life
    "device_type",  # known at signup
]

POST_EVENT_FIELDS: set[str] = set()  # fill in the leakage fields


class SignupRequest(BaseModel):
    plan_tier: str
    signup_channel: str
    device_type: str
    # add only origination-time fields


overlap = POST_EVENT_FIELDS & SignupRequest.model_fields.keys()
print("leakage in schema:", overlap)
assert not overlap
print("ok")
Hint Run the one-direction audit on each field: at the instant of signup, before the subscription has run a single day, is this value knowable? A field that can only have a value once time has passed or once the cancellation has happened is post-event. Re-read the paragraph on the per-column audit.

Solution

Sort the candidate fields by the one-direction audit and build a SignupRequest that declares only the signup-time fields. Watch the assertion pass because no post-event field crossed into the schema, the same structural guarantee the loan contract gave.

python
from pydantic import BaseModel

POST_EVENT_FIELDS = {
    "total_months_active",  # accrues only as the subscription runs
    "cancellation_reason",  # exists only if/after they cancel -- label proxy
    "lifetime_revenue",  # accumulates over the subscription's life
}


class SignupRequest(BaseModel):
    plan_tier: str
    signup_channel: str
    device_type: str


overlap = POST_EVENT_FIELDS & SignupRequest.model_fields.keys()
print("leakage in schema:", overlap)
assert not overlap
print("ok -- every post-event field is excluded by construction")

cancellation_reason is the recoveries of this dataset: it exists almost only when the predicted outcome has already happened, so it is a near-perfect label proxy. The audit question, “is this known at signup?”, answers each field independently, and the three that fail it never enter the schema, which is what makes the exclusion enforceable instead of a comment everyone is supposed to remember.

Return a prediction the caller can build on

The request side is now hardened. The response side carries a mirror-image trap. The wrong mental model treats the response as a single number, so the endpoint returns a bare float. One consumer, one float, no ceremony, and it reads clean. Almost no production API has one consumer. Other teams’ services build against /predict, and the shape it returns becomes an API surface every one of them hard-codes against.

A response schema is a contract with those consumers, and a contract is a reliability mechanism, not a courtesy. Without a defined, explicit contract, consumers integrate against whatever the endpoint happens to return today, and then either side can break the other: a consumer parses a field that is later renamed, or the endpoint adds information and the consumer that did score = response.json() (expecting a bare number) gets a dict and crashes. The contract is the boundary that prevents both. FastAPI enforces it through response_model, a declared pydantic model that validates the returned data, serializes it to JSON, and limits and filters the output to the declared fields. That filtering is the security property: a field that is not declared cannot leak out, even if the handler accidentally returns it. The validation runs the other direction from the request: if the handler returns something the response model rejects, that is a 500, because the bug is in the server’s code, not the caller’s.

Returning a bare scalar has a named failure mode: no slot to grow. The day the endpoint needs to also return the threshold-applied label, or the model version, there is no place to put it without changing the shape every caller parses. A structured object reserves named slots, so adding a field is backward-compatible: a consumer reading only probability is untouched when model_version is added. The reason adding a field is safe is structural: a JSON object is keyed, so a new key is invisible to a reader that does not look for it, whereas a bare value has no keys to ignore. The schema below returns a structured prediction; watch what the caller gets beyond the raw number:

python
from pydantic import BaseModel


class PredictionResponse(BaseModel):
    probability: float
    label: bool
    model_version: str


def predict_one(annual_inc: float, threshold: float = 0.5) -> PredictionResponse:
    # Stand-in for model.predict_proba; the shape is the point, not the math.
    proba = 0.5 + (annual_inc - 40000) / 400000
    proba = max(0.0, min(1.0, proba))
    return PredictionResponse(
        probability=round(proba, 4),
        label=proba >= threshold,
        model_version="loan-default-2026-06-01",
    )


resp = predict_one(annual_inc=70000)
print(resp.model_dump())

The caller now receives the threshold-applied label and the raw probability, so it does not hard-code the decision threshold itself: when risk policy moves the threshold, the change ships in the service, not in every consumer. The model_version field is the one that earns its place during an incident. I once got an alert over a weekend that a batch of accounts had scored badly, and I could not answer the first question the incident demanded, which model produced these scores?, because the endpoint had returned a bare probability and I had shipped two model versions that week. I spent half a Saturday reconstructing my own deploy timeline from commit messages. A one-line model_version field in the response, logged with every prediction, would have answered it in seconds. The non-obvious cost of the structured object is that it commits the contract to those names earlier: probability and label are now contract, and renaming either is a breaking change once a consumer reads it. This lesson owns defining the contract and why it is a reliability boundary; the machinery of evolving it safely once consumers depend on it (versioning, deprecation windows, backward-compatible rollout) is downstream in the cloud-deployment and MLOps modules.

What forces pydantic here, rather than a plain dataclass, is the same property that made the request side work: pydantic validates at runtime, and a dataclass does not. A dataclass with the right field types will happily construct from wrong-typed data and serialize it, so a bug that put a string in probability would ship to the caller silently. The pydantic response model catches it at the boundary as a server error visible in the service’s own logs, not a malformed payload the consumer discovers. This service now validates at all three of its boundaries with one discipline: the request schema checks what callers send, the response model checks what it returns, and the typed Settings(BaseSettings) object from the packaging module checks what it is configured with at startup — the same machinery, pointed at the environment instead of the request body, failing fast with the field named either way.


Try It 3

This response schema returns only probability, leaving every caller to apply the decision threshold themselves, so a threshold change means editing every consumer. Extend it to also return a label derived from the threshold, so the decision is made once, in the service. Use a movie-rating context (predicting whether a film scores above 7.0) rather than loans.

python
from pydantic import BaseModel


class RatingResponse(BaseModel):
    probability: float
    # add a label field here


def predict_rating(score_signal: float, threshold: float = 0.5) -> RatingResponse:
    proba = max(0.0, min(1.0, score_signal))
    return RatingResponse(
        probability=round(proba, 4),
        # set the label from the threshold
    )


print(predict_rating(0.81).model_dump())
Hint The label is a boolean derived from comparing the probability to the threshold, the same `proba >= threshold` logic the loan example used. What field gets added to the schema, and what expression sets it inside the constructor? Re-read the paragraph on why returning the label moves the threshold decision into the service.

Solution

Add a label field derived from comparing the probability to the threshold inside the constructor, so the decision is computed once in the service. Watch the response carry both the raw probability and the threshold-applied label, with the rating context standing in for the loan.

python
from pydantic import BaseModel


class RatingResponse(BaseModel):
    probability: float
    label: bool


def predict_rating(score_signal: float, threshold: float = 0.5) -> RatingResponse:
    proba = max(0.0, min(1.0, score_signal))
    return RatingResponse(
        probability=round(proba, 4),
        label=proba >= threshold,
    )


print(predict_rating(0.81).model_dump())

The caller now reads label directly and never sees the threshold, so when the product team decides 7.0 should become 7.5 the change lives in one service instead of in every consumer’s copy of the comparison. Adding label next to probability is backward-compatible, since a consumer that only read probability is unaffected, which is the structured-object property that a bare float threw away.


Summary

  • A pydantic request schema validates at the boundary, at construction time, so malformed input becomes a clear 422 naming the field instead of an opaque 500 from deep inside the model: the misattributed-500 is what this avoids.
  • pydantic coerces types, so “validated” means “coercible to the declared type,” not “exactly that type”; a numeric string passes a float field by default, so constrain explicitly when that matters.
  • The request schema, not the model code, is the enforcement point for leakage exclusion: a field that is not declared cannot be sent or forwarded, and assert not (LEAKAGE_FIELDS & Schema.model_fields.keys()) makes the exclusion a CI test.
  • Leakage exclusion is a per-column audit run in one direction, “is this known at the scoring instant?”, not a memorized blocklist; the dozen post-outcome Lending Club columns fail that question, recoveries most sharply because it is a near-perfect label proxy.
  • A structured response with named fields (probability, label, model_version) is a reliability contract: adding a field is backward-compatible, and the version field is what lets an incident attribute a score to a specific model.

Check your understanding:

  • A caller sends loan_amnt as the string "ten thousand". Without scrolling up: what status code should the endpoint return, where in the request lifecycle is the error caught, and why is that better than the model raising?
  • Your model was retrained without recoveries, but the request schema still lists it. Is the leakage trap closed? What is still wrong, and what one line in a test would have caught it?
  • Two teams consume your /predict. You need to start returning the model version. Why does a structured response let you add it without breaking either consumer, and what would have broken if the endpoint returned a bare float?

This lesson is part of Pro

The Ship a Machine Learning Product path — every lesson, capstone, and the failure modes free tutorials skip. Sign in if you already have Pro, or unlock it below.

Unlock with Pro Sign in