change retry intervals

This commit is contained in:
Neal Shah
2026-07-24 18:23:22 -04:00
parent b712b0b0c9
commit 08e06f53e5
2 changed files with 54 additions and 17 deletions
+30 -15
View File
@@ -248,9 +248,17 @@ async function bodyForLog (xhr) {
// spec, so there are no hand-listed argument lists to get wrong.
const RETRYABLE_STATUS = new Set([ 429, 502, 503, 504 ]);
const MAX_ATTEMPTS = 5; // transient attempts (network / retryable 5xx / 429)
const BASE_DELAY_MS = 500;
const MAX_DELAY_MS = 60_000; // cap each backoff wait at 1 minute
// Fixed retry backoff: a quick ramp to a 2s ceiling, then hold at 2s. Index i is
// the wait (ms) after attempt i+1 fails. The array length caps the retries — 8
// delays ⇒ 9 attempts total, the 2s ceiling used 5 times — after which the
// request is failed.
const RETRY_DELAYS_MS = [ 250, 500, 1000, 2000, 2000, 2000, 2000, 2000 ];
const RETRY_CEILING_MS = 2000;
// If a ceiling-length wait overruns real time by more than this, the clock
// jumped (e.g. the laptop slept mid-wait); the request is stale, so give up
// rather than fire a very old retry.
const MAX_SLEEP_DRIFT_MS = 2000;
// Kill-switch seam: puter.configure() (deferred) will drive this. Default on.
const autoRetryEnabled = () => globalThis.puter?.config?.autoRetry ?? true;
@@ -264,8 +272,13 @@ const sleep = (ms, signal) => new Promise((resolve, reject) => {
}, { once: true });
});
// Full-jitter exponential backoff, each wait capped at MAX_DELAY_MS.
const backoffDelay = attempt => Math.random() * Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** (attempt - 1));
const retryDelay = attempt => RETRY_DELAYS_MS[attempt - 1];
const transientRetry = ctx => {
if ( ! (ctx.retrySafe && autoRetryEnabled()) ) return null;
const delayMs = retryDelay(ctx.attempt);
return delayMs === undefined ? null : { delayMs };
};
/**
* Drive the env-specific permission prompt for a denied driver call.
@@ -369,10 +382,7 @@ function sendOnce (spec) {
async function classifyRetry (outcome, ctx) {
if ( outcome.streamed ) return null; // committed stream — never retried
if ( outcome.networkError ) {
return ( ctx.retrySafe && autoRetryEnabled() && ctx.attempt < MAX_ATTEMPTS )
? { delayMs: backoffDelay(ctx.attempt) } : null;
}
if ( outcome.networkError ) return transientRetry(ctx);
const { xhr, status } = outcome;
if ( outcome.parsed === undefined ) {
@@ -399,11 +409,8 @@ async function classifyRetry (outcome, ctx) {
return null;
}
// transient status — read-safe only, honors kill switch, bounded + backed off.
if ( RETRYABLE_STATUS.has(status) ) {
return ( ctx.retrySafe && autoRetryEnabled() && ctx.attempt < MAX_ATTEMPTS )
? { delayMs: backoffDelay(ctx.attempt) } : null;
}
// transient status — read-safe only, honors kill switch, fixed schedule.
if ( RETRYABLE_STATUS.has(status) ) return transientRetry(ctx);
return null;
}
@@ -427,7 +434,15 @@ async function sendWithRetry (spec, { retrySafe = false, permission = null, shap
const outcome = await sendOnce(spec);
if ( outcome.streamed ) return shapeStream(outcome.lineStream, outcome.xhr);
const decision = await classifyRetry(outcome, ctx);
if ( decision ) { await sleep(decision.delayMs, spec.signal); continue; }
if ( decision ) {
const before = Date.now();
await sleep(decision.delayMs, spec.signal);
if ( decision.delayMs >= RETRY_CEILING_MS
&& (Date.now() - before) - decision.delayMs > MAX_SLEEP_DRIFT_MS ) {
return shape(outcome);
}
continue;
}
return shape(outcome);
}
}
+24 -2
View File
@@ -293,10 +293,10 @@ describe('transient retry', () => {
vi.useFakeTimers();
const xhrs = installFakeXHR(netError()); // every attempt fails
const p = fetchUrl('https://api.example/x').catch(e => e); // GET
await vi.advanceTimersByTimeAsync(60_000 * 6);
await vi.advanceTimersByTimeAsync(60_000); // clears the ~11.75s schedule
const err = await p;
expect(err).toBeInstanceOf(TypeError);
expect(xhrs.length).toBe(5); // MAX_ATTEMPTS
expect(xhrs.length).toBe(9); // 1 initial + 8 scheduled retries
vi.useRealTimers();
});
@@ -305,6 +305,28 @@ describe('transient retry', () => {
await expect(fetchUrl('https://api.example/x', { method: 'POST' })).rejects.toThrow(/failed/);
expect(xhrs.length).toBe(1);
});
it('gives up on a 2s retry when the clock jumps (sleep/drift guard)', async () => {
// Fake only the timers, not Date — a manual `clock` drives Date.now so we
// can simulate the machine sleeping during a 2s ceiling wait.
vi.useFakeTimers({ toFake: [ 'setTimeout', 'clearTimeout' ] });
let clock = 0;
vi.spyOn(Date, 'now').mockImplementation(() => clock);
const xhrs = installFakeXHR(respond({ status: 503, body: {} })); // always retryable
const p = fetchUrl('https://api.example/x'); // GET → read-safe
// Ramp (250+500+1000) → 4 attempts, then paused in the first 2s wait.
// Sub-2s waits aren't drift-guarded, so the clock needn't move here.
await vi.advanceTimersByTimeAsync(1750);
// Simulate the laptop sleeping through the 2s wait: the clock leaps ahead.
clock += 2000 + 60_000;
await vi.advanceTimersByTimeAsync(2000);
const resp = await p;
expect(resp.status).toBe(503); // failed with the last outcome — no further retry
expect(xhrs.length).toBe(4); // stopped after the drifted 2s wait, before attempt 5
vi.useRealTimers();
});
});
describe('dedupe', () => {