Serialize LTI Advantage token refreshes with an advisory lock - #7451
Open
santicomp2014 wants to merge 1 commit into
Open
Serialize LTI Advantage token refreshes with an advisory lock#7451santicomp2014 wants to merge 1 commit into
santicomp2014 wants to merge 1 commit into
Conversation
There is one cached LTIA token per (registration, scopes), enforced by a unique constraint, and JWTOAuth2TokenService.EXPIRATION_LEEWAY retires it 60s before it actually expires. Canvas tokens live 3600s, so once an hour that single row goes stale -- and _get_access_token had no concurrency control, so every request in flight at that moment missed the cache and issued its own identical client_credentials POST. Herd size is therefore equal to concurrency at the moment of expiry, which is why this has been harmless for years and then wasn't: a school with two concurrent grading reads sends two token requests, while one large institution at the start of term sends dozens, every hour, and the LMS starts throttling the developer key. Instructure throttles by queueing rather than returning 429, so the requests hang until they time out with no status code at all. Take the same advisory lock oauth_http already takes for user-token refreshes, so exactly one request refreshes and the rest retry. Two details worth knowing: The lock is keyed on LTIRegistration.id rather than on the jwt_oauth2_token row, because there is no row to key on the first time a registration needs a token. Registrations using several scope sets will serialize against each other, which is broader than strictly necessary and harmless. The loser raises ConcurrentTokenRefreshError instead of waiting for the winner. It cannot usefully wait: the winner's token only becomes visible when its transaction commits, at the end of its request, not ours -- so a waiter would re-read, find nothing, and refresh anyway. Raising surfaces as a 409, which the frontend already treats as retryable, and the retry arrives as a fresh request with a fresh transaction that can see the committed token. We re-read the token after acquiring the lock, since the winner may have committed between our first read and us getting the lock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
santicomp2014
force-pushed
the
fix/serialize-ltia-token-refresh
branch
from
August 28, 2026 16:02
405c38d to
1207fe6
Compare
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.
The problem
There is one cached LTIA token per
(registration, scopes), enforced by a unique constraint, andJWTOAuth2TokenService.EXPIRATION_LEEWAYretires it 60s before it actually expires. Canvas tokens live 3600s, so once an hour that single row goes stale — and_get_access_tokenhad no concurrency control, so every request in flight at that moment missed the cache and issued its own identicalclient_credentialsPOST.Herd size is therefore equal to concurrency at the moment of expiry, which is why this has been harmless for years and then wasn't: a school with two concurrent grading reads sends two token requests, while one large institution at the start of term sends dozens, every hour, and the LMS starts throttling the developer key. Instructure throttles by queueing rather than returning 429, so the requests hang until they time out with no status code at all.
The fix
Take the same advisory lock
oauth_httpalready takes for user-token refreshes, so exactly one request refreshes and the rest retry.Two details worth knowing:
The lock is keyed on
LTIRegistration.id, not on thejwt_oauth2_tokenrow, because there is no row to key on the first time a registration needs a token. Registrations using several scope sets will serialize against each other, which is broader than strictly necessary and harmless.The loser raises
ConcurrentTokenRefreshErrorinstead of waiting for the winner. It cannot usefully wait: the winner's token only becomes visible when its transaction commits, at the end of its request, not ours — so a waiter would re-read, find nothing, and refresh anyway. Raising surfaces as a 409, which the frontend already treats as retryable (utils/api.ts—status === 409,maxRetries = 10, 1s backoff), and the retry arrives as a fresh request with a fresh transaction that can see the committed token.We re-read the token after acquiring the lock, since the winner may have committed between our first read and us getting the lock.
Changes
lms/db/_locks.py— newLockType.LTIA_TOKEN_REFRESH = 2lms/services/jwt_oauth2_token.py— newtry_lock_for_refresh()lms/services/ltia_http.py—_get_access_tokennow locks, re-reads, then refreshesTesting
New tests added:
test_try_lock_for_refresh— asserts the lock is keyed on the registrationtest_request_locks_before_refreshing_the_tokentest_request_raises_ConcurrentTokenRefreshError_when_locked— asserts the loser makes no token requesttest_request_uses_a_token_refreshed_while_it_waited_for_the_lock— covers the re-readWhat to expect in Sentry after this deploys
Two things will look like regressions and aren't.
1. The token-timeout issue ID has moved. #7449 renamed the exception on this path to
LTIATokenRequestError, so LMS-1CY has flatlined and the same failure now lands under a newissue. When verifying this PR, read the transaction rather than an issue ID:
2.
concurrent_token_refresh409s will appear on this path for the first time. That's thelock working — the request that loses the refresh race raises
ConcurrentTokenRefreshError,which the existing exception view maps to 409, and the frontend already retries 409s. No
frontend change was needed.
How to read that rate:
maxRetries: 2.That points at the token endpoint still being slow, not at this change being wrong.
No threshold on that one yet — there's no baseline. Dashboard for a week, then set one.
Success criterion for this PR: token POSTs per expiry, per registration, should be 1
rather than equal to concurrency. ECU's submission-to-launch ratio should move from ~0.040 back
toward its healthy 0.241.
🤖 Generated with Claude Code