fix(review): close 5 follow-up gaps from Stage 6 audit re-review
- F-T-003 actually closed: clients/typescript/src/subscribe.ts now bounds the 401-refresh loop at 3 consecutive refusals, surfaces an onError describing the loop, and resets on any successful stream open. New test covers the cap. The Stage 2 dashboard fix only addressed adminRequest in dashboard/src/lib/api.ts; the originally- cited TS client file was untouched. - users/invitations subtab dropped the redundant api.apps.get fetch the other 5 subtabs already shed in Stage 6. - Queue visibility-timeout validator pulled out as validate_queue_visibility_timeout, with two thresholds: hard-reject below MIN_QUEUE_VISIBILITY_TIMEOUT_SECS (30s — catches typos), warn- log between MIN and SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS (300s) so operators see when their visibility is below the dispatcher's per-message executor budget. Stage 6 only had the hard floor; the reviewer caught that a 60s handler still races a 30s visibility even after the floor. Four new unit tests cover none/above-safe/ between/below-min. - pic dead-letters count subcommand: cheap headless probe for unresolved DL totals, parallels the dashboard's badge query. - pic dead-letters replay now has a happy-path integration test (replay_against_real_dl_row_succeeds): inserts a synthetic DL row directly via the rust-postgres sync driver (added as dev-dep), drives `pic dead-letters replay`, asserts the row is resolved with reason=replayed and count drops back to 0. Plus a count smoke test. All 75 CLI integration tests + 16 TS client tests + 4 new visibility-timeout unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,14 @@ export function subscribeTopic<T = unknown>(
|
||||
let lastEventId: string | undefined;
|
||||
let controller: AbortController | null = null;
|
||||
let backoffTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// F-T-003: bound the 401-refresh loop. If onTokenExpired keeps
|
||||
// returning a token that the server rejects (mis-issued refresh,
|
||||
// server-side authz drift, …) we'd otherwise reconnect immediately
|
||||
// forever with attempt=0 each iteration — the previous behavior.
|
||||
// Three consecutive 401s within the loop give up and surface the
|
||||
// error so the caller knows the credential is bad.
|
||||
let consecutive401 = 0;
|
||||
const MAX_CONSECUTIVE_401 = 3;
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
@@ -61,6 +69,17 @@ export function subscribeTopic<T = unknown>(
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
consecutive401 += 1;
|
||||
if (consecutive401 >= MAX_CONSECUTIVE_401) {
|
||||
opts.onError?.(
|
||||
new Error(
|
||||
`realtime subscribe stuck in 401-refresh loop (${consecutive401} consecutive); ` +
|
||||
'check that onTokenExpired returns a credential the server accepts'
|
||||
)
|
||||
);
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
// Token expired / rejected — try to refresh, else give up.
|
||||
const fresh = opts.onTokenExpired ? await opts.onTokenExpired() : null;
|
||||
if (fresh) {
|
||||
@@ -79,8 +98,10 @@ export function subscribeTopic<T = unknown>(
|
||||
return;
|
||||
}
|
||||
|
||||
// Connected — reset backoff and stream frames until the body ends.
|
||||
// Connected — reset backoff and the 401 counter; a successful
|
||||
// open proves the current credential works.
|
||||
attempt = 0;
|
||||
consecutive401 = 0;
|
||||
try {
|
||||
await readStream(res.body, (frame) => {
|
||||
if (frame.id !== undefined) lastEventId = frame.id;
|
||||
|
||||
@@ -96,4 +96,30 @@ describe('subscribe', () => {
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalled());
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('caps the 401-refresh loop after consecutive failures', async () => {
|
||||
// onTokenExpired keeps returning a fresh-looking-but-still-rejected
|
||||
// token. Without the cap the loop would reconnect forever.
|
||||
const fetchMock = queuedFetch([
|
||||
async () => emptyResponse(401),
|
||||
async () => emptyResponse(401),
|
||||
async () => emptyResponse(401),
|
||||
async () => emptyResponse(401),
|
||||
async () => emptyResponse(401)
|
||||
]);
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
const onError = vi.fn();
|
||||
const onTokenExpired = vi.fn(() => 'never-good-enough');
|
||||
const unsubscribe = client.subscribe('chat', () => {}, {
|
||||
onTokenExpired,
|
||||
onError
|
||||
});
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalled(), { timeout: 1000 });
|
||||
unsubscribe();
|
||||
// The cap is 3 consecutive 401s — at most 3 fetches should have
|
||||
// been issued before the loop bailed.
|
||||
expect(fetchMock.mock.calls.length).toBeLessThanOrEqual(3);
|
||||
const errMsg = String(onError.mock.calls[0]?.[0]);
|
||||
expect(errMsg).toContain('401-refresh loop');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user