fix(oauth): resume status polling after an accepted callback (#486)

A failed status request (a proxy 502, a network drop or a timeout) stops
polling and marks the card failed, but keeps the callback field. If the
user then submits the callback URL, the backend accepts it, since the
login is still pending, and saves the credential. Nothing polls again,
so the card keeps showing the old error.

The backend accepts callbacks only for pending logins, so resume polling
after an accepted callback when it is not already running. Attempts
track whether their poll loop is active.

Signed-off-by: bob <cx742296960@gmail.com>
This commit is contained in:
Bob
2026-10-08 21:34:54 +08:00
committed by GitHub
parent 5aa1ad64a6
commit 00bb5a3b22
3 changed files with 75 additions and 1 deletions
+7
View File
@@ -580,6 +580,13 @@ export function OAuthPage() {
if (!attempt.isCurrent()) return;
updateProviderState(provider, { callbackSubmitting: false, callbackStatus: 'success' });
showNotification(t('auth_login.oauth_callback_success'), 'success');
// The backend only accepts callbacks for pending logins. If a failed status
// request stopped polling, resume it so the card can report the outcome.
const loginState = states[provider]?.state;
if (loginState && !attempt.isPolling()) {
updateProviderState(provider, { status: 'waiting', error: undefined, polling: true });
startPolling(provider, loginState, attempt);
}
} catch (err: unknown) {
if (!attempt.isCurrent()) return;
const status = getErrorStatus(err);
+9 -1
View File
@@ -1,6 +1,8 @@
export interface OAuthAttempt {
readonly signal: AbortSignal;
isCurrent: () => boolean;
/** True from poll() until a terminal result, a request error or invalidation. */
isPolling: () => boolean;
invalidate: () => void;
schedule: (callback: () => void, delay: number) => void;
poll: <T>(
@@ -24,10 +26,12 @@ export function createOAuthAttempts(scheduler: Scheduler) {
const begin = (provider: string): OAuthAttempt => {
attempts.get(provider)?.invalidate();
let timer: number | undefined;
let polling = false;
const controller = new AbortController();
const attempt: OAuthAttempt = {
signal: controller.signal,
isCurrent: () => attempts.get(provider) === attempt,
isPolling: () => polling && attempt.isCurrent(),
invalidate: () => {
if (timer !== undefined) scheduler.clearTimeout(timer);
timer = undefined;
@@ -43,13 +47,17 @@ export function createOAuthAttempts(scheduler: Scheduler) {
}, delay);
},
poll: (request, onResult, onError, delay) => {
polling = true;
const tick = async () => {
try {
const result = await request();
if (!attempt.isCurrent()) return;
if (onResult(result)) attempt.schedule(() => void tick(), delay);
else polling = false;
} catch (error: unknown) {
if (attempt.isCurrent()) onError(error);
if (!attempt.isCurrent()) return;
polling = false;
onError(error);
}
};
attempt.schedule(() => void tick(), delay);
+59
View File
@@ -210,6 +210,65 @@ describe('OAuth attempt lifecycle', () => {
}
});
test('a failed status request ends polling and the same attempt can resume it', async () => {
const { attempts, tasks, tick } = setup();
const responses: Array<() => Promise<string>> = [
async () => {
throw new Error('Request failed with status code 502');
},
async () => 'wait',
async () => 'ok',
];
const results: string[] = [];
let errors = 0;
const attempt = attempts.begin('codex');
const poll = () =>
attempt.poll(
() => responses.shift()!(),
(result) => {
results.push(result);
return result === 'wait';
},
() => errors++,
3000
);
expect(attempt.isPolling()).toBe(false);
poll();
expect(attempt.isPolling()).toBe(true);
tick();
await flush();
expect(errors).toBe(1);
expect(attempt.isPolling()).toBe(false);
expect(tasks.size).toBe(0);
// An accepted callback resumes polling on the attempt that still owns the login.
poll();
expect(attempt.isPolling()).toBe(true);
tick();
await flush();
expect(attempt.isPolling()).toBe(true);
tick();
await flush();
expect(results).toEqual(['wait', 'ok']);
expect(attempt.isPolling()).toBe(false);
expect(tasks.size).toBe(0);
});
test('an invalidated attempt never reports an active poll', () => {
const { attempts } = setup();
const old = attempts.begin('codex');
old.poll(
async () => 'wait',
() => true,
() => {},
3000
);
expect(old.isPolling()).toBe(true);
const next = attempts.begin('codex');
expect(old.isPolling()).toBe(false);
expect(next.isPolling()).toBe(false);
});
test('terminal results and request errors do not schedule another poll', async () => {
for (const fail of [false, true]) {
const { attempts, tasks, tick } = setup();