Skip to content

Comments

fix: TaskContextError for cross-task usage (issue #576)#600

Open
gspeter-max wants to merge 1 commit intoanthropics:mainfrom
gspeter-max:issue_576
Open

fix: TaskContextError for cross-task usage (issue #576)#600
gspeter-max wants to merge 1 commit intoanthropics:mainfrom
gspeter-max:issue_576

Conversation

@gspeter-max
Copy link

Implementation: Fix for Issue #576 - Cross-Task Detection

Implements suggestion #2 from the issue: detect cross-task usage and raise a clear error instead of silently hanging.

What Was Changed

1. New Exception: TaskContextError

  • Added TaskContextError exception class to _errors.py
  • Stores connect_task_id and current_task_id for debugging
  • Includes helpful error message with guidance

2. Task Tracking in Query Class

  • Added _owner_task field to track which task owns the Query instance
  • Captures task ID in start() using anyio.get_current_task()
  • Validates task context in receive_messages()

3. Updated Documentation

  • Updated caveat in client.py to reference the new error
  • Added FastAPI example showing correct usage patterns

4. Comprehensive Tests

  • tests/test_task_context.py: 6 new tests covering:
    • Same-task usage (works)
    • Cross-task usage (raises TaskContextError)
    • Multiple clients in different tasks (all work)
    • Error attributes and messages

Example of the New Behavior

Before (silent hang):

client = ClaudeSDKClient()
await client.connect()  # Task A

async def handle_request():
    async for msg in client.receive_messages():  # Task B
        yield msg
    # ← Hangs here forever with no error

After (clear error):

client = ClaudeSDKClient()
await client.connect()  # Task A

async def handle_request():
    async for msg in client.receive_messages():  # Task B
        yield msg
    # ← Raises: TaskContextError: ClaudeSDKClient cannot be used across 
    #    different async tasks. Each async task must create its own client 
    #    instance. See TaskContextError documentation for details.
    #    (connect task: 12345, current task: 67890)

Correct Usage for FastAPI

@app.post("/query")
async def query_endpoint(prompt: str):
    # Create client per-request, in the request handler's task
    async with ClaudeSDKClient() as client:
        await client.query(prompt)
        async for msg in client.receive_messages():
            yield msg

See examples/fastapi_example.py for complete working example.

Note on Test Changes

One existing test (test_concurrent_send_receive in test_streaming_client.py) was removed because it tested the exact cross-task pattern we're now preventing. This test:

  • Used asyncio.create_task() to call receive_messages() from a different task
  • Only passed with mocked transports (false confidence)
  • Would hang with real transports (the bug we're fixing)
  • Violated the documented caveat about cross-task usage

Cross-task usage has never been supported (documented since v0.0.20), and this test was giving false confidence about a buggy pattern.

Verification

  • ✅ All 161 tests pass
  • ✅ Type checking passes (mypy)
  • ✅ Linting passes (ruff)
  • ✅ New tests demonstrate the error is raised correctly
  • ✅ FastAPI example added

Breaking Changes

None. This only adds validation that was missing. Existing valid single-task usage is unchanged.


Ready for review! Feedback welcome on the error message wording, documentation, or approach.

Fixes #576

Root Cause:
- ClaudeSDKClient silently hangs when receive_messages() is called from
  a different async task than where connect() was called
- anyio MemoryObjectStream is bound to the task group where created
- Common scenario: FastAPI/Starlette apps with global client

Changes:
1. Add TaskContextError exception class
   - Stores connect_task_id and current_task_id for debugging
   - Includes helpful error message with documentation reference
   - Follows existing error class patterns

2. Track task ownership in Query class
   - Add _owner_task field to track creating task
   - Capture task ID in start() using anyio.get_current_task()
   - Validate task context in receive_messages()

3. Update documentation
   - Update caveat in client.py to reference TaskContextError
   - Explain FastAPI/Starlette scenario

4. Add comprehensive tests (test_task_context.py)
   - Same-task usage works
   - Cross-task usage raises TaskContextError
   - Multiple clients in different tasks work
   - Error attributes and messages verified

5. Add FastAPI example (examples/fastapi_example.py)
   - Shows correct per-request client pattern
   - Documents wrong pattern (global client)

6. Remove test_concurrent_send_receive
   - Tested cross-task pattern that causes hangs
   - Never was supported (documented caveat)
   - Only passed with mocks, gave false confidence

Impact:
- Converts silent hang into fast, actionable error
- No breaking changes for valid usage
- Existing single-task usage unchanged
- Cross-task usage now fails with clear guidance

Refs: anthropics#576
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.

ClaudeSDKClient silently hangs when reused across ASGI request tasks (FastAPI/Starlette)

2 participants