Repository navigation
Bug: anyio.Lock in async_auth_flow causes RuntimeError under concurrent OAuth MCP connections #2847
Description
Summary
Confirmed production occurrence of the bug described in #2644 (self-closed by the original reporter before they could follow through). Filing to provide a second validated repro and draw attention to the existing draft fix in #2660.
Environment
mcp==1.26.0anyio==4.13.0httpx==0.28.1- Python 3.11.14, macOS
What happens
When a gateway session starts and connects to multiple OAuth-authenticated MCP servers concurrently (Notion + TinyFish, both OAuth 2.1 PKCE), Notion fails intermittently:
ERROR asyncio: Task exception was never retrieved
RuntimeError: The current task is not holding this lock
File ".../mcp/client/auth/oauth2.py", line 503, in async_auth_flow
File ".../mcp/client/auth/oauth2.py", line 484, in async_auth_flow
raise RuntimeError("The current task is not holding this lock")
WARNING tools.mcp_tool: MCP server 'notion' connection lost (attempt 1/5), reconnecting in 1s
WARNING tools.mcp_tool: Failed to connect to MCP server 'notion': CancelledError
INFO tools.mcp_tool: MCP: registered 114 tool(s) from 4 server(s) (1 failed)
Notion is affected more often than TinyFish because its OAuth token refreshes frequently (~every 15–60 min), consistently triggering the refresh yield path in async_auth_flow. TinyFish tokens expire less often and typically take the happy path (add header, yield once).
Root cause
OAuthContext.lock is anyio.Lock, which records task identity at acquire() and enforces same-task release(). async_auth_flow is an async generator that holds this lock across yield points. When httpx drives the generator from a different task during concurrent connections, anyio.Lock.release() throws.
Existing draft fix
PR #2660 addresses this correctly by narrowing lock scope so no lock is held across yields — GET SSE long-polls and token refresh yields both run outside any lock. It has full test coverage (100% on oauth2.py, 1177 passed) and no breaking changes, but has been sitting as a draft without maintainer review since May 22.
Workaround applied locally
Replacing anyio.Lock with asyncio.Lock in OAuthContext stops the error since asyncio.Lock does not enforce task identity on release. This is a bandaid — it loses trio portability — but unblocks asyncio deployments. Note: this is a mechanical fix and has not been load-tested to exhaustion; the intermittent nature of the bug means full verification requires sustained concurrent load.
Request
Could a maintainer review and merge PR #2660? The fix is principled and well-tested.
Activity
Bartok9 commentedon Jun 13, 2026
Confirmed the root-cause analysis here is correct and the existing fix is the right one.
Verified on current
main:OAuthContext.lockisanyio.Lock(records task identity atacquire(), enforces same-taskrelease()), andasync_auth_flowholds it acrossyieldpoints (oauth2.py— theasync with self.context.lock:wraps theyield refresh_request/yield request/ full 401 flow). When httpx drives the generator from a different task during concurrent connections,release()raisesRuntimeError: The current task is not holding this lock— exactly the trace here and in #2644.PR #2660 fixes this correctly by narrowing lock scope (no HTTP
yieldruns while holdingcontext.lock) plus a single-flightrefresh_lock. It was sittingCONFLICTINGsince May 27; I rebased it onto currentmain— conflict is small and isolated to the refresh block, and the rebased branch is green (108 auth tests pass, ruff clean). Details + branch on #2660. Theasyncio.Lockswap mentioned here works for asyncio-only deployments but loses trio portability, so #2660's narrow-the-scope approach is preferable.Bartok9 commentedon Jun 13, 2026
Opened #2858 with a ready-to-merge fix: a conflict-free rebase of @peisuke's #2660 onto current
main(all credit to @peisuke for the fix). Narrowscontext.lockscope so no HTTPyieldruns while the lock is held, with a single-flightrefresh_lock— keeps trio portability. Green: 108 auth tests pass, ruff clean. This should unblock the concurrent-OAuthRuntimeErrorreported here.Bartok9 commentedon Jun 13, 2026
@Ben-Home — this is really valuable production data, thank you. The "~15% failures → zero after jitter" number is exactly the kind of real-world signal worth acting on, and we did: I just opened #2859 to bring proactive refresh + jitter into the SDK, motivated directly by your comment.
A couple of clarifications on the current architecture, since they shape where each fix belongs:
On lock sharing across connectors: In this SDK the lock isn't actually shared between connectors.
OAuthContext(which owns the lock) is instantiated perOAuthClientProvider, and there's one provider per server connection — so Notion and Shopify already have independent locks. There's no global/module-level lock; it'sfield(default_factory=anyio.Lock)on a per-instance context. So "Notion refresh blocks Shopify" shouldn't occur from lock contention today — the per-connector isolation you'd want is already the structure.On auth vs refresh lock (your point #3): #2858 already does this. It keeps
context.lockfor short state reads/writes only, adds a dedicated single-flightrefresh_lock, and runs all HTTPyields (including the long-poll GET SSE) outside both locks. So the initial PKCE flow and token refresh no longer contend, and a slow refresh round-trip doesn't block unrelated requests. (#2858 fixes theRuntimeErrorcorrectness bug; it isn't a Lock→Semaphore swap.)On your jitter idea (#2) — this is the genuinely new lever, so I built it: #2859 adds
calculate_token_refresh_time()— refresh at ~80% of TTL minus bounded random jitter (≤30s, always subtracted so it can never overshoot hard expiry), tracked astoken_refresh_timewith ashould_refresh_token()predicate.async_auth_flownow refreshes proactively when past the jittered window (not only reactively at hard expiry). Because each provider's refresh point is independently jittered, a fleet's tokens stop clustering into the same ~60s window — which is precisely the thundering-herd you described. Fully backward-compatible (noexpires_in→ stays purely reactive). 12 new tests, full auth suite green, ruff + pyright clean.Would genuinely love to see your refresh-stagger implementation — if your approach differs (e.g. different fraction/jitter bounds, or per-fleet coordination rather than per-connector), that's worth folding in or discussing on #2859. Thanks again for pushing on this.
fix(oauth): narrow async_auth_flow lock scope to avoid blocking long-…
fix(oauth): narrow async_auth_flow lock scope to avoid blocking long-…
diaanhw commentedon Jul 8, 2026
Independent production confirmation on a long-running gateway (hermes-agent 0.16.0), with an escalation path worth noting for long-lived processes.
Environment:
mcp==1.26.0,anyio==4.12.1,httpx==0.28.1, Python 3.11.14, macOS arm64. Four OAuth 2.1/PKCE streamable-HTTP MCP servers (Reportify) connected concurrently from one process.Trace (verbatim; three separate incidents on 2026-07-04/06/07):
This is the generator being closed (
GeneratorExitdelivered at theyield) from a task other than the one that enteredasync with self.context.lock:— so it reproduces without retries or redirects; connection teardown during a suspended auth flow is enough. Immediately followed by:Why this is worse than an aborted request in long-lived deployments: after the RuntimeError the server's connection is dead inside the still-running gateway, so periodic token refresh for it stops. Our provider's refresh token has a bounded lifetime (~25 h); once it ages past that, refresh is rejected (
Token refresh failed: 400) and the only way back is a full interactive browser re-auth. An "intermittent" failure escalates into credential loss whenever the process outlives the refresh-token lifetime.Also confirming the earlier comment about current state: v1.28.1 and
mainstill holdcontext.lockacross the yields (oauth2.py:493on v1.28.1), so no released version avoids this today. +1 for maintainer review of #2858.miggitty commentedon Sep 3, 2026
Confirming this, and adding a second trigger path that is more common than concurrency and considerably more severe than an intermittent
RuntimeError.The lock is not just noisy — it is permanently poisoned
anyio.Lock.release()raises before clearing_owner_task(anyio/_backends/_asyncio.py:1933). So when the cross-task release fails, the lock remains owned by a task that no longer exists. Any consumer that caches itsOAuthClientProvider— reusing it across reconnects, as several do — then deadlocks inasync with self.context.lockon every subsequent request, forever, without ever reaching the network.In our case that turned into an 11-hour silent outage: reconnect attempts every ~5.5 minutes, all expiring on
connect_timeout, with zero HTTP requests emitted the entire time. It reads like a network failure and isn't one. Only a process restart recovered it.A trigger that does not require concurrency
The reported path is httpx resuming the generator from a different task under concurrent connections. There is a second one that needs only a single connection:
Any consumer that abandons the
async_auth_flowgenerator rather than closing it.async_auth_flowsuspends atyield requestwhile holding the lock. If it is dropped, CPython hands it to asyncio's async-generator finalizer, which runsathrow(GeneratorExit)in a newly created task — so the cross-task release is not a race, it is guaranteed to fail 100% of the time.Abandonment is easy to hit accidentally. Ours came from a wrapper that bridged the bidirectional protocol and held the inner generator in a local without an
aclose(). It also arises whenever a streaming request is torn down mid-flight — for us, the streamable-HTTP GET stream against a server that answers 405 to GET, sohandle_get_streamtook the exception path on every single connect.Standalone repro (no server, no network, no concurrency)
Reproduced on Python 3.11.14 / anyio 4.14.2 with both mcp 2.0.0 and the current
oauth2.py, which still holdsanyio.Lockacross the yield — so upgrading is not a workaround today.Implication for the fix
This argues for #2858's approach specifically: narrowing the scope so no lock is held across a
yieldremoves both trigger paths at once. Swapping inasyncio.Lockalso stops the poisoning (no owner affinity, so the finalizer's release succeeds) and works as a local stopgap, but it costs trio portability and converts any future same-task re-acquisition from a loud error into a silent deadlock — so it isn't a good upstream answer.Downstream consumers who cache providers may also want to rebuild the provider when an OAuth connection exhausts its retries, since nothing in-process can currently un-poison a cached one.
Happy to test a patch against a real 405-on-GET OAuth server if that's useful.
AkshayShah03 commentedon Oct 13, 2026
Confirmed on main (754254c), but I think the trigger is different from the one in the original report.
With httpx2 I couldn't get it to fail through concurrent connections: 2000 concurrent requests, with forced refreshes and random client timeouts, gave no errors and never left the lock held.
_send_handling_authdrives the auth generator and callsaclose()on it in the same task.What does reproduce it every time is an auth-flow generator that is suspended at
yield requestand then dropped withoutaclose(). asyncio finalizes it in a new task. That task tries to releasecontext.lock, which_auth_flowholds across the yield (oauth2.py:606), and you get the exact error from the report:The worse part: anyio raises before it clears the owner, so the lock stays held. The next request on the same provider then hangs forever in
async with self.context.lock, without sending any HTTP request.Minimal shape:
That gives a deterministic regression test for whichever fix lands: abandon the flow in one task, then assert a request from another task completes and
lock.locked()is False. #2858 narrows the lock scope, which should cover this, but it predates the move toRedirectAwareAuthand probably needs a rebase. Full script: https://gist.github.com/AkshayShah03/3cea1dead96b4792a7c3e4d2d71cd7f7Repro written with AI assistance; I ran it and checked the output.