Skip to content

fix: don't freeze Problem so it survives yield-dependency unwinding (#9)#12

Merged
PythonFZ merged 3 commits into
mainfrom
worktree-dev+issue-9-unfreeze-problem
Jun 19, 2026
Merged

fix: don't freeze Problem so it survives yield-dependency unwinding (#9)#12
PythonFZ merged 3 commits into
mainfrom
worktree-dev+issue-9-unfreeze-problem

Conversation

@PythonFZ

Copy link
Copy Markdown
Owner

Fixes #9.

Problem

Raising a Problem through (or behind) a FastAPI yield dependency returned a generic 500 instead of the intended status:

@app.get("/via-yield")
async def via_yield(_=Depends(yield_dep)):
    raise Missing(detail="nope")   # -> 500, not 404

Root cause

A Problem is also an Exception. FastAPI runs yield dependencies inside an AsyncExitStack; as the exception unwinds back out, contextlib reassigns exc.__traceback__ at the Python level:

File ".../contextlib.py", line 255, in __aexit__
    exc.__traceback__ = traceback
File "<string>", line 4, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field '__traceback__'

Problem was a frozen=True Pydantic dataclass, so that assignment raised FrozenInstanceError, which masked the real problem and fell through to the catch-all 500 handler. Routes without yield dependencies never hit the Python-level reassignment (CPython sets __traceback__ at the C level on the initial raise), which is why it went unnoticed.

Fix

An exception must be writable at the Python level by design, so rather than special-casing __setattr__, the Problem dataclass is simply no longer frozen:

  • frozen=False, eq=False in _ProblemMetaeq=False keeps an exception's normal identity-based equality and hashing (a non-frozen dataclass would otherwise synthesize value-based, unhashable instances).
  • Dropped the frozen_default=True @dataclass_transform hint so the static contract matches runtime (no "frozen" claim the runtime doesn't enforce).

The handlers already treat a raised problem as read-only (build_wire never mutates it), so reusing a shared module-level constant across requests stays safe — now verified by test_state_safety_shared_problem_across_paths rather than relying on immutability.

Tests (TDD — written first, watched fail with FrozenInstanceError/500, then fixed)

  • test_yield_dependency.py
    • issue scenario: raise through a yield dependency → real status, not 500
    • raise through nested yield deps → detail, instance, and typed extension fields all survive (no information lost)
    • catch one problem and escalate to a different problem type through a guarded route → response reflects the new type/status
  • test_problem.py
    • test_fields_are_mutable_problem_is_not_frozen (replaces the old frozen-mutation test)
    • test_exception_dunders_reassignable_with_no_info_lost__traceback__ / __cause__ / __context__ / __suppress_context__ / add_note all reassignable, values intact

Full suite: 92 passed. ruff check, ruff format, and pyright all pass.

🤖 Generated with Claude Code

PythonFZ and others added 3 commits June 19, 2026 08:15
…ing (#9)

A `Problem` is also an `Exception`. FastAPI runs `yield` dependencies inside
an `AsyncExitStack`; as an exception unwinds back out, `contextlib` reassigns
`exc.__traceback__` at the Python level. While `Problem` was a frozen Pydantic
dataclass that assignment raised `FrozenInstanceError`, masking the raised
problem as a generic 500. Routes without `yield` dependencies never hit the
Python-level reassignment (CPython sets `__traceback__` at the C level on the
initial raise), which is why it went unnoticed.

Make the dataclass non-frozen (`frozen=False, eq=False`) and drop the
`frozen_default=True` static hint, rather than special-casing `__setattr__`:
an exception must be writable at the Python level by design. `eq=False` keeps
an exception's normal identity-based equality and hashing. The handlers already
treat a raised problem as read-only, so reusing a shared module-level constant
stays safe.

Tests: raise through single and nested `yield` dependencies (no field/payload
lost), re-raise as a different problem type through a guarded route, and a unit
test that the BaseException dunders are reassignable with no information lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removed unnecessary comments about the dataclass behavior.
Updated docstring to clarify instance resolution behavior.
@PythonFZ PythonFZ merged commit 2f6118f into main Jun 19, 2026
4 checks passed
@PythonFZ PythonFZ deleted the worktree-dev+issue-9-unfreeze-problem branch June 19, 2026 06:24
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.

Raising a Problem through a yield dependency fails with FrozenInstanceError

1 participant