A typed API client that crashes on valid responses: pydantic int_type when the field is an object
Symptom
I was driving a time-tracking service through a typed Python wrapper: pydantic v2 models over its REST API, exposed to an assistant as MCP tools. Creating an entry worked. Updating one worked. Listing entries for a date range worked. Exactly two operations failed, and they failed every single time, with the same input that the working calls accepted happily.
Fetching a single record by id blew up on a rate field — the model declared one type for it, the API sent
another. Asking for the currently running timer through its dedicated endpoint blew up on a pydantic
validation error whose type was int_type, pointing at the field that identifies the project.
That mix is the confusing part. If the credentials were wrong, nothing would work. If the service were down, nothing would work. Instead the wrapper was 80 % functional and the broken 20 % was broken deterministically.
What it is not
The reflex in this situation is to suspect access: a token that is valid for listing but not for reading a
single record, a permission that only covers some endpoints, a scope missing from the API key. The second
reflex is to suspect the transport — a httpx or requests version mismatch inside the wrapper, a
redirect, a content-type negotiation problem.
Both are cheap to rule out, and you should do it before you open a single line of the wrapper’s source. Make the same call by hand and look at the bytes:
curl -sS -H "Authorization: Bearer $TOKEN" \
-w '\n%{http_code}\n' \
https://api.example.com/api/timesheets/1234
If the status is 200 and the JSON body looks like the record you asked for, the server did its job.
Authentication cannot be the problem, because an authentication problem is a 401 or a 403, not a
payload. The HTTP layer cannot be the problem either, because you just exercised it successfully with a
completely different client. The request succeeded and the parse failed — every remaining suspect lives
on your side of the wire.
That is a ten-second test and it moves the investigation from “what is wrong with the service” to “what is wrong with my model”, which are two entirely different days of work.
What I measured
With the raw JSON from both endpoints side by side, the difference was immediately visible in a field that the wrapper treats as a plain foreign key.
The list endpoint returns the project as a scalar:
{ "id": 1234, "project": 12, "begin": "2026-08-28T09:00:00+0200" }
The detail endpoint returns the same logical field as an expanded object:
{ "id": 1234, "project": { "id": 12, "name": "…", "customer": { "id": 3 } } }
Same field name, same meaning, two shapes — chosen by the endpoint and by which serialization group the
server applies to that response. The wrapper’s model had been written from one of those responses, almost
certainly a list response, and then reused for all of them. So it declared project: int.
That is exactly what int_type means in pydantic v2. It is not a parsing failure like a string that does
not contain digits; it is the stricter complaint “you promised an integer and I was handed something of a
different type”. The offending input, a dict, is right there in the error together with the field path.
The error is a correct report about the model, not a bug in the server and not a corrupt response.
The rate field on the detail endpoint failed for the same structural reason, one level down: the shape the model declared and the shape the API produced for that endpoint were not the same shape.
You can reproduce the whole class of failure in isolation, with no API at all:
from pydantic import BaseModel
class TimeEntry(BaseModel):
id: int
project: int
TimeEntry.model_validate({"id": 1234, "project": 12}) # fine
TimeEntry.model_validate({"id": 1234, "project": {"id": 12}}) # int_type
If that snippet fails the way your wrapper fails, you have confirmed the diagnosis without touching the network again.
The fix
Accept both shapes at the boundary and reduce them to one shape immediately, so that nothing downstream
ever has to ask which endpoint the data came from. In pydantic v2 that is a mode="before" validator,
which runs on the raw input, before the type of the annotation is enforced:
from typing import Any
from pydantic import BaseModel, field_validator
class TimeEntry(BaseModel):
id: int
project: int # always an id by the time anyone sees it
@field_validator("project", mode="before")
@classmethod
def _unwrap(cls, value: Any) -> Any:
if isinstance(value, dict):
return value.get("id")
return value
Both payloads now validate and entry.project is an int in both cases. If several fields expand the
same way — project, activity, user, customer — a single model_validator(mode="before") over the whole
payload keeps it to one place:
from pydantic import model_validator
EXPANDABLE = ("project", "activity", "user")
class TimeEntry(BaseModel):
...
@model_validator(mode="before")
@classmethod
def _flatten(cls, data: Any) -> Any:
if isinstance(data, dict):
data = dict(data)
for key in EXPANDABLE:
if isinstance(data.get(key), dict):
data[key] = data[key].get("id")
return data
When you genuinely need the expanded fields — the project’s name for a report, the customer behind it — keep the union instead of collapsing it, and give callers one accessor so they still never branch:
class ProjectRef(BaseModel):
id: int
name: str | None = None
class TimeEntry(BaseModel):
id: int
project: int | ProjectRef
@property
def project_id(self) -> int:
p = self.project
return p.id if isinstance(p, ProjectRef) else p
If you control neither the model nor the caller, the third option is to declare the field as Any at the
boundary and convert in exactly one function. It is less precise, but it keeps the conversion in a single
place instead of scattering isinstance checks through the calling code, and a single place can be fixed
later.
What you must not do is wrap the validation in a try and return None on failure. That turns a
data-shape bug — loud, local, one line to fix — into silently missing data that surfaces hours later as a
wrong total in a report. A crash you can read is worth more than a zero you cannot explain.
The workaround while the wrapper is broken
I needed the running timer before I needed a patched dependency. The dedicated endpoint for it was the broken one — but the list endpoint, filtered to active entries, returned the same record, in the scalar shape the model already understood:
GET /api/timesheets?active=1
One call, same data, no fork of the library, no waiting for an upstream release. That generalises: when a typed client breaks on one endpoint, look for an adjacent endpoint that carries the same information. APIs that expand objects usually do so on detail routes and not on collection routes, so the collection route is frequently the one your model can still parse. Under time pressure that beats patching a dependency, and it costs nothing if you patch the model properly afterwards.
What to remember
A schema written or generated from one sample response is a snapshot, not a contract. Any API that
supports field expansion, include= parameters or serialization groups will return different shapes for
the same field depending on which route you call, and a strict client converts that variation into a crash
at exactly the moment you reach for the data.
So: validate at the boundary, normalise there and nowhere else, and keep the raw payload around for the
cases the model did not anticipate — a dict field populated from the original response costs nothing and
saves the next debugging session, because you can see what arrived instead of only what failed to fit.
It is the mirror image of the wrapper that exposed too few fields: there the client was wrong about which data existed, here it was wrong about what shape the data has. Both are the same underlying mistake — trusting an abstraction over an API more than the API itself — and both are found the same way, by making one raw request and reading the JSON.