mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-10-16 14:57:05 +08:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user