errno

A typed API client that crashes on valid responses: pydantic int_type when the field is an object

· in APIs and automation · tested on Python, pydantic v2 models over a REST API, an MCP wrapper around a time-tracking service

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.

python pydantic api mcp