Auto-retry once after CSRF token resync in lt.api (#86)

lt.api's fetch wrapper (_apiFetchAuth in base.js — the live
implementation lt.api.* resolves to) already resynced
window.CSRF_TOKEN from a 403 response's csrf_token field, but still
threw immediately — every caller saw a raw "Invalid CSRF token" error
on the FIRST attempt, with no transparent retry. Since CsrfMiddleware's
token lifetime (1h) is shorter than the session idle timeout (5h),
this was a routine, fully recoverable case (an hour of page
inactivity, or a write in another tab rotating the shared token), not
a real rejection.

After resyncing the token from a 403 body that carries one, now
retries the original request exactly once with the fresh token before
surfacing an error — transparent to the caller on the common case,
with a `retried` flag preventing more than one retry so a genuinely
broken session still fails cleanly instead of looping. Verified via
jsdom with a mocked fetch: a 403-then-succeeds sequence resolves
successfully with exactly 2 network calls and the correct final
token; a persistently-403 sequence still throws after exactly 2 calls
(no infinite retry).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
This commit is contained in:
2026-09-11 13:31:25 -04:00
co-authored by Claude Sonnet 5
parent 5b96e75ff6
commit ae12fcd6fd
+10 -1
View File
@@ -2801,7 +2801,7 @@
};
// Patch lt.api — auth-aware wrapper (renamed to avoid strict-mode duplicate declaration)
async function _apiFetchAuth(method, url, body) {
async function _apiFetchAuth(method, url, body, retried) {
if (_authAccess && auth.isExpiringSoon()) await auth.refresh();
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess;
@@ -2821,6 +2821,15 @@
// Resync CSRF token from any response body that carries a fresh one
// (bootstrap rotates on success and returns the current token on rejection).
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
// Auto-retry once on a stale-CSRF-token 403: the token lifetime (1h) is
// shorter than the session idle timeout (5h), so this is a routine,
// recoverable case (an hour of inactivity, or a write in another tab
// rotating the shared token) rather than a real rejection — resyncing
// above already has the fresh token, so silently resending once succeeds
// transparently instead of surfacing a confusing error on the first try.
if (resp.status === 403 && !retried && data && data.csrf_token) {
return _apiFetchAuth(method, url, body, true);
}
if (!resp.ok) {
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
err.data = data;