Skip to content

fix(models): do not cache a discriminator mapping built from unbuilt schemas - #2025

Open
sushant-me wants to merge 1 commit into
anthropics:mainfrom
sushant-me:fix/discriminator-cache-incomplete-schema
Open

sushant-me wants to merge 1 commit into
anthropics:mainfrom
sushant-me:fix/discriminator-cache-incomplete-schema

Conversation

@sushant-me

Copy link
Copy Markdown

Summary

A discriminated union caches its key → variant mapping on the union for the life of the process. _build_discriminated_union_meta reads each variant's __pydantic_core_schema__, and the SDK defers Pydantic builds — so a variant that another thread is still building has no readable schema yet. That variant is silently skipped, and the resulting incomplete mapping is cached anyway, which leaves the union decoding to the wrong variant until the process restarts.

The first-variant fallback in construct_type exists to absorb a discriminator/literal mismatch, but it cannot help here: the discriminator lookup succeeds and silently returns the wrong type.

The fix

An incomplete mapping is returned to the caller but not cached, so the next call rebuilds it once the deferred build has finished. Complete mappings cache exactly as before.

The part that makes this more than a one-line change, and worth a reviewer's attention: the if not field: continue in that loop conflates two different situations, so the None return from _extract_field_schema_pv2 could not simply be reused.

  • a variant whose schema has not finished building cannot contribute → the mapping is incomplete
  • a variant that genuinely does not declare the discriminator legitimately contributes nothing → the mapping is still cacheable

Treating both as incomplete would mean never caching any real mapping, so _has_built_model_schema distinguishes them by inspecting whether the variant's schema is a model/model-fields schema yet.

Tests

test_discriminated_unions_incomplete_mapping_is_not_cached is deterministic rather than threaded — it replaces one variant's __pydantic_core_schema__ with an unbuilt one ({"type": "definition-ref"}), which is exactly the state the racing thread sees, so nothing depends on thread timing. It asserts:

  • the incomplete mapping is not cached, while a usable result is still returned for the variants that can be read
  • once every schema is readable, the complete mapping is cached and contains both variants

Verified:

# with the fix
tests/test_models.py ....... 62 passed

# revert only the caching guard
E   AssertionError: an incomplete discriminator mapping must not be cached
FAILED tests/test_models.py::test_discriminated_unions_incomplete_mapping_is_not_cached

uv run --isolated --all-extras pytest tests/test_models.py -q → 62 passed.

Notes

  • Pydantic v1 takes the same complete path via the missing-field_info branch.
  • I did not change DEFER_PYDANTIC_BUILD, and this does not make the first concurrent decodes faster. It only stops one unlucky thread from poisoning the union permanently.

This mirrors a bug reported against the equivalent generated SDK at openai/openai-python#4036, where the same code shape exists; that repository restricts PR creation to collaborators, so the analysis went into the issue there instead. I am flagging it here because the defect is present in this codebase independently of that one.

…schemas

Reading a discriminated union caches the key -> variant mapping on the union for
the life of the process. `_build_discriminated_union_meta` reads each variant's
`__pydantic_core_schema__`, and the SDK defers Pydantic builds, so a variant that
another thread is still building has no readable schema yet. That variant was
silently skipped and the resulting incomplete mapping was cached anyway, so the
union then decoded to the wrong variant until the process restarted, with the
first-variant fallback that exists for exactly this kind of mismatch made useless
because the discriminator lookup silently pointed at the wrong type.

The skip in that loop conflated two different situations, which is why the `None`
return from `_extract_field_schema_pv2` could not be reused: a variant whose
schema has not finished building cannot contribute, whereas a variant that
genuinely does not declare the discriminator legitimately contributes nothing.

`_has_built_model_schema` separates them. A variant whose schema is not yet a
model/model-fields schema marks the mapping incomplete, and an incomplete mapping
is returned to the caller but not cached, so the next call rebuilds it once the
build has finished. Complete mappings cache exactly as before.

The test is deterministic rather than threaded: it replaces one variant's
`__pydantic_core_schema__` with an unbuilt one, which is the state the racing
thread produces, so no thread timing is involved.
@sushant-me
sushant-me requested a review from a team as a code owner October 18, 2026 03:06

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant