Skip to content

fix(server): serve(transport="both", public_url=callable) crashes at startup#681

Closed
bokelley wants to merge 3 commits into
mainfrom
claude/issue-676-composed-lifespan-callable-public-url
Closed

fix(server): serve(transport="both", public_url=callable) crashes at startup#681
bokelley wants to merge 3 commits into
mainfrom
claude/issue-676-composed-lifespan-callable-public-url

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Closes #676

Summary

serve(transport="both", public_url=callable) exited 0 at startup with AttributeError: 'function' object has no attribute 'router' in _composed_lifespan.

Root cause: When public_url is a callable, create_a2a_server builds a Starlette app then wraps it with _wrap_with_per_request_card, which returns a plain async def _middleware(scope, receive, send) function — not a Starlette app. The lifespan composer in _build_mcp_and_a2a_app then called a2a_inner.router.lifespan_context(a2a_inner), which crashes because plain functions have no .router. The string and None cases return a Starlette app directly, which is why they were unaffected.

Fix:

  • a2a_server.py: in the callable branch of create_a2a_server, keep the Starlette app in a local (starlette_app) before wrapping, then attach ._starlette_app = starlette_app on the returned middleware function.
  • serve.py _composed_lifespan: use getattr(a2a_inner, "_starlette_app", a2a_inner) to reach the inner Starlette app's router. Falls back to a2a_inner itself for the static/None path — no behaviour change there.
  • Added _wrap_with_per_request_card comment noting the post-hoc attribute attachment.

What was tested

  • pytest tests/test_a2a_public_url_resolver.py tests/test_unified_mcp_a2a.py tests/test_serve_auth_both.py -v56 passed (including new regression test)
  • ruff check src/adcp/server/a2a_server.py src/adcp/server/serve.py — no issues
  • mypy src/adcp/server/a2a_server.py src/adcp/server/serve.py --ignore-missing-imports — no new errors in changed files

Nits surfaced by pre-PR review (not fixed in this PR)

  • No test for auth=BearerTokenAuth(...) + public_url=callable on transport="both" together — fix logic is auth-independent but the coverage gap exists; follow-up issue recommended
  • ._starlette_app as a duck-typed cross-module attribute is fragile if _wrap_with_per_request_card is ever converted to a class without reading the comment; a Protocol would make the contract explicit

Pre-PR review

  • code-reviewer: approved — no blockers; fix correctly passes starlette_app (not middleware wrapper) as the lifespan_context argument; nits noted above
  • dx-expert: approved — fix is correct and minimal; @pytest.mark.asyncio added for consistency per nit

Triage-managed PR. This bot does not currently iterate on
review comments or PR conversation threads (only on the source
issue). To unblock:

  • Push fixup commits directly: gh pr checkout <num>
    fix → push.
  • Or re-trigger: comment /triage execute on the source
    issue.

See adcp#3121
for context.

Session: https://claude.ai/code/session_01He3UUyDxcy4pmyExJKCHNY


Generated by Claude Code

claude added 2 commits May 11, 2026 21:06
#676)

When public_url is a callable, create_a2a_server returns the
_wrap_with_per_request_card middleware wrapper (a plain async function)
instead of a Starlette app.  _build_mcp_and_a2a_app's lifespan composer
then called a2a_inner.router.lifespan_context(), which fails because
plain functions have no .router.

Fix: attach a ._starlette_app back-reference to the middleware wrapper
so _composed_lifespan can reach the inner Starlette app's router.  Use
getattr(a2a_inner, '_starlette_app', a2a_inner) in the composer so the
static/None path is unchanged.

Adds a regression test in test_a2a_public_url_resolver.py.

https://claude.ai/code/session_01He3UUyDxcy4pmyExJKCHNY
Per pre-PR review nits:
- Add @pytest.mark.asyncio to new regression test for consistency with
  all other async tests in test_a2a_public_url_resolver.py
- Add comment at _wrap_with_per_request_card's return site noting that
  create_a2a_server attaches ._starlette_app on the returned function

https://claude.ai/code/session_01He3UUyDxcy4pmyExJKCHNY
CI mypy flagged the [attr-defined] suppression as unused — the wrapper
function's typed return is permissive enough that mypy accepts the
attribute assignment without the suppression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley
Copy link
Copy Markdown
Contributor Author

Closing as superseded. Main already merged a cleaner fix for #676 (closed at 2026-05-12T00:52:05Z): _PerRequestCardMiddleware as a proper Starlette middleware class via app.add_middleware(...), which keeps app as a Starlette instance throughout and avoids the duck-typed ._starlette_app back-reference this PR introduced.

That's the architecturally better solution. Closing this PR rather than rebasing on top of the conflict, per the parallel-race convention in CLAUDE.md ("Stale claude-triaged draft PRs lose parallel races... Prefer closing-as-superseded over rebasing.").

@bokelley bokelley closed this May 12, 2026
@bokelley bokelley deleted the claude/issue-676-composed-lifespan-callable-public-url branch May 12, 2026 01:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

serve(transport="both") + callable public_url crashes at startup: 'function' object has no attribute 'router'

2 participants