Skip to content

fix(http-client-python): make Model.__new__ lazy init thread-safe#11236

Draft
l0lawrence wants to merge 2 commits into
mainfrom
fix-python-model-new-thread-safety
Draft

fix(http-client-python): make Model.__new__ lazy init thread-safe#11236
l0lawrence wants to merge 2 commits into
mainfrom
fix-python-model-new-thread-safety

Conversation

@l0lawrence

@l0lawrence l0lawrence commented Jul 13, 2026

Copy link
Copy Markdown
Member

Fixes #11234 (from Azure/azure-sdk-for-python#47426).

What was going wrong

Every generated model class builds a small table of its fields (_attr_to_rest_field) the first time you create one of its objects, then caches it on the class.

If your program creates the first objects of several related models at the same time from multiple threads (for example, an app handling its first requests from a thread pool right after startup), that first-time setup could crash with RuntimeError: dictionary changed size during iteration — one thread was reading a parent class's attributes while another thread was writing the cache onto that same parent.

The crash never surfaced, because deserialization catches any error and falls back to returning the raw JSON. So instead of an error, you'd silently get back a plain dict (or a model with raw-dict fields inside) instead of a real model object — only on the first few responses after startup, only under concurrency. Very hard to diagnose.

The fix (no locks)

Reworked the first-time setup in Model.__new__ so it's safe when several threads hit it at once:

  1. Read from a copy. We now copy each class's attributes before reading them (dict(mro_class.__dict__)), so another thread writing the cache can't change what we're reading mid-loop. This removes the crash. The copy has all the same fields, because a class's fields are fixed when it's defined — only cached, _-prefixed values get added later, and those are filtered out anyway.
  2. Save the finished table last, in one step. The extra maps (backcompat, XML plan) are saved first; _attr_to_rest_field is built fully in a local variable and assigned last. So the moment another thread sees it, everything is ready — no half-built state.
  3. Use the cache itself as the "done" flag. We check "_attr_to_rest_field" not in cls.__dict__ (this class's own attributes) instead of a shared set that every class wrote into. This is correct per-class and removes another piece of unsynchronized shared state.

Out of scope (follow-up)

Deserialization still silently swallows any error and returns the raw JSON. Fixing the race removes this trigger, but that broad catch would hide future errors the same way — worth a separate discussion rather than changing fallback behavior across every SDK here.

@microsoft-github-policy-service microsoft-github-policy-service Bot added the emitter:client:python Issue for the Python client emitter: @typespec/http-client-python label Jul 13, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@typespec/http-client-python@11236

commit: 876e13d

@l0lawrence l0lawrence changed the title fix(http-client-python): make Model.__new__ lazy init thread-safe without locks fix(http-client-python): make Model.__new__ lazy init thread-safe Jul 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

All changed packages have been documented.

  • @typespec/http-client-python
Show changes

@typespec/http-client-python - fix ✏️

Fix a race condition in the generated _model_base.py where concurrent first-time model construction could raise RuntimeError: dictionary changed size during iteration in Model.__new__. Because deserialization swallows that error, affected responses were silently returned as raw dicts (or models with raw-dict nested fields) instead of deserialized models. The lazy metadata initialization is now thread-safe without locks: it iterates a snapshot of each class __dict__ and publishes _attr_to_rest_field atomically, guarded by the class's own __dict__.

@azure-sdk-automation

azure-sdk-automation Bot commented Jul 13, 2026

Copy link
Copy Markdown

You can try these changes here

🛝 Playground 🌐 Website 🛝 VSCode Extension

@l0lawrence l0lawrence force-pushed the fix-python-model-new-thread-safety branch from d7347da to 10c448d Compare July 13, 2026 20:03
…hout locks

Concurrent first-time model construction could raise
"RuntimeError: dictionary changed size during iteration" in Model.__new__,
which deserialization silently swallowed, returning raw dicts instead of
models. Iterate a snapshot of each class __dict__ and publish
_attr_to_rest_field atomically, guarded by the class's own __dict__.

Fixes #11234

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@l0lawrence l0lawrence force-pushed the fix-python-model-new-thread-safety branch from 10c448d to 4451ec6 Compare July 13, 2026 20:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

emitter:client:python Issue for the Python client emitter: @typespec/http-client-python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[http-client-python] model.__new__ investigation

1 participant