fix(oauth): rebuild the OAuthRefreshTokenSource refresh lock per event loop - #3790
Open
pcbeingused333 wants to merge 1 commit into
Open
fix(oauth): rebuild the OAuthRefreshTokenSource refresh lock per event loop#3790pcbeingused333 wants to merge 1 commit into
pcbeingused333 wants to merge 1 commit into
Conversation
…t loop
`resolve_async` created its `asyncio.Lock` lazily and then kept it for the
lifetime of the source. An `asyncio.Lock` binds itself to the loop that first
awaits it under contention and raises `RuntimeError` when awaited from any
other one, so a source reused across event loops — one `asyncio.run` per
request is a common deployment — failed on the second loop's first contended
refresh with:
RuntimeError: <asyncio.locks.Lock object> is bound to a different event loop
Contention is not an edge case here: collapsing a burst of concurrent callers
into a single network refresh is the only reason the lock exists. An
uncontended acquire never binds the loop, which is why the existing async test
did not catch this.
`_get_async_lock` now returns the lock for the running loop and rebuilds it
when the loop changes. A dedicated `_async_lock_guard` covers the check and the
assignment — never an await — so two threads driving separate loops cannot each
install a lock and lose mutual exclusion between them. `_sync_lock` was not
reused for this: `resolve` holds it across a blocking network call, and an
async caller waiting on it would stall its event loop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pcbeingused333
requested review from
bogdankostic
and removed request for
a team
August 16, 2026 03:42
Contributor
Coverage report (oauth)Click to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||
Contributor
|
Hi @pcbeingused333, thanks a lot for your contribution! 🙏 We noticed that the Contributor License Agreement (CLA) check ( To get your PR reviewed, please sign the CLA via the link in the |
HaystackBot
marked this pull request as draft
August 16, 2026 04:59
HaystackBot
marked this pull request as ready for review
August 17, 2026 22:55
Contributor
|
Thanks for signing the CLA, @pcbeingused333! 🎉 This PR is now ready for review again and the reviewer has been re-assigned. |
This was referenced Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related Issues
Proposed Changes:
OAuthRefreshTokenSource.resolve_asyncbuilt itsasyncio.Locklazily and then kept it for the lifetime of the source. Anasyncio.Lockbinds to the loop that first awaits it under contention and raisesRuntimeErrorwhen awaited from another one, so a source reused across event loops — oneasyncio.runper request is a common deployment — failed on the second loop's first contended refresh:Contention is not an edge case here: collapsing a burst of concurrent callers into a single network refresh is the only reason the lock exists.
_get_async_locknow returns the lock for the running loop and rebuilds it when the loop changes. A dedicated_async_lock_guard(athreading.Lock) covers the check and the assignment, never an await, so two threads driving separate loops cannot each install a lock and lose mutual exclusion between them._sync_lockwas deliberately not reused for that guard:resolveholds it across a blocking network call, so an async caller waiting on it would stall its own event loop for the duration of a token refresh.OAuthTokenExchangeSourceneeded no change — it holds athreading.Lockonly around cache access and never across the network call, as its own comment says.How did you test it?
Two tests added to
TestOAuthRefreshTokenSource:test_resolve_async_works_in_a_second_event_loopis the regression pin: it drives two concurrent callers underasyncio.run, then does it again on a fresh loop with the same source.test_concurrent_resolve_async_refreshes_oncepins the property the lock exists for — four concurrent callers, one network refresh. It passes before and after; it is there so a future change to the locking cannot quietly drop the coalescing.I checked the regression test is not vacuous: with
sources.pyreverted tomainit fails with theRuntimeErrorabove, raised fromasyncio/mixins.py:20— the bug itself, not an incidental assertion mismatch. The standalone reproduction in #3789 was run both ways too (fails onmain, passes on this branch).Both halves of the test have to contend for the lock or it proves nothing: an uncontended
Lock.acquire()returns before it ever calls_get_loop(), so the loop is never bound. That is exactly why the existingtest_resolve_async_success_and_cachesdid not catch this, and the_slow_posthelper exists to force the overlap.hatch run test:unit tests/test_sources.py→ 33 passed.hatch run test:typesandhatch run fmt-check .clean.Notes for the reviewer
Two judgement calls worth a look:
Rebuilding on loop change rather than keeping a lock per loop. If two event loops in two threads use the same source simultaneously, each one rebuilds the lock and mutual exclusion between them is lost. A
WeakKeyDictionarykeyed by loop would hold in that case too. I did not do it because the class is documented as single-identity and "use a single instance in either sync or async mode, not both", so the realistic pattern is sequential loops, not simultaneous ones — but say the word and I will switch it.The extra
threading.Lock. It is one more attribute on a class that already has one lock. Without it, the check-and-set is not atomic across threads. Happy to drop it if you consider cross-thread use out of scope.Checklist
fix:,feat:,build:,chore:,ci:,docs:,style:,refactor:,perf:,test:.