Repository navigation
fix(http-client-python): make Model.__new__ lazy init thread-safe - #11236
Libba Lawrence (l0lawrence) wants to merge 6 commits into
Conversation
commit: |
|
All changed packages have been documented.
Show changes
|
|
You can try these changes here
|
d7347da to
10c448d
Compare
…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>
10c448d to
4451ec6
Compare
Python emitter diffBaseline Diff summary: 210 file(s), +3998 / -1898 Rendered diff: inline on the run summary, or the emitter-diff-html artifact. Informational check (eng/emitter-diff); does not block the PR. |
|
Hi @Libba Lawrence (@l0lawrence). Your PR has had no update for 14 days and it is marked as a stale PR. If it is not updated within 14 additional days, the PR will automatically be closed. If you want to refresh the PR, please remove the |
|
Hi @Libba Lawrence (@l0lawrence). The PR will be closed since the PR has no update for 28 days. If this is still relevant please reopen. |
iscai-msft
left a comment
There was a problem hiding this comment.
looks good, wondering if you've noticed a perf hit?
I can regenerate an sdk and test it 7–11% slowdown for individual polymorphic models in the synthetic benchmark |
There was a problem hiding this comment.
🟡 Changes recommended
A critical concurrency race remains in discriminated-model deserialization: cache assignment can interrupt iteration over the class dictionary, causing a suppressed RuntimeError and a raw-dict result; the reader needs a snapshot and discriminated-model regression coverage.
1 open issue
What changed in this PR
This PR addresses a race in the Python emitter’s generated model initialization that can silently return raw dictionaries during concurrent first-time deserialization.
Changes:
- Snapshot class attributes before scanning field metadata.
- Publish the completed field cache last and use it as the per-class initialization flag.
- Add concurrent deserialization regression coverage and a changelog entry.
| File | Description |
|---|---|
packages/http-client-python/tests/unit/test_model_base_concurrency.py |
Adds concurrent first-use tests with nested models. |
packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 |
Updates lazy metadata initialization and cache publication. |
.chronus/changes/fix-model-new-thread-safety-2026-7-13-11-33-46.md |
Documents the race-condition fix for the Python emitter. |
Review effort: Balanced
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

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: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._attr_to_rest_fieldis built fully in a local variable and assigned last. So the moment another thread sees it, everything is ready — no half-built state."_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.