mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
* freehire-search: honor Retry-After on 429/503 apiGet retried a 429 on its own ~24s schedule and ignored Retry-After, while freehire's limits are per minute and every refusal states how long to wait, so a rate-limited run burned all six retries inside the window it had just been refused for. Wait max(backoff, Retry-After), capped at 60s. * freehire-search: bound the total retry wait; fail at once past it * changelog: place the #632 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
5.2 KiB
TypeScript
149 lines
5.2 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import { apiGet, retryAfterMs } from "../src/helpers";
|
|
|
|
// The portal contract requires backoff on 429/5xx. These tests pin the retry
|
|
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
|
|
// fires immediately so the exhaustion case does not sleep through the real
|
|
// 500ms -> 8s backoff schedule. apiGet's documented graceful-degradation
|
|
// contract (connection failures fail fast, no retry) is pinned too.
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const originalSetTimeout = globalThis.setTimeout;
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
globalThis.setTimeout = originalSetTimeout;
|
|
});
|
|
|
|
function instantTimers() {
|
|
globalThis.setTimeout = ((fn: () => void) =>
|
|
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
|
|
}
|
|
|
|
function stubFetch(responses: Array<() => Response>): { calls: number } {
|
|
const state = { calls: 0 };
|
|
globalThis.fetch = (async () => {
|
|
const i = Math.min(state.calls, responses.length - 1);
|
|
state.calls++;
|
|
return responses[i]();
|
|
}) as unknown as typeof fetch;
|
|
return state;
|
|
}
|
|
|
|
describe("apiGet retry/backoff", () => {
|
|
test("retries a 429 and succeeds on the next attempt", async () => {
|
|
instantTimers();
|
|
const state = stubFetch([
|
|
() => new Response("", { status: 429 }),
|
|
() => new Response('{"data":[]}', { status: 200 }),
|
|
]);
|
|
|
|
const envelope = await apiGet<unknown[]>("/x");
|
|
expect(envelope).not.toBeNull();
|
|
expect(state.calls).toBe(2);
|
|
});
|
|
|
|
test("returns the documented null on 404 without retrying", async () => {
|
|
const state = stubFetch([() => new Response("", { status: 404 })]);
|
|
|
|
const envelope = await apiGet("/x");
|
|
expect(envelope).toBeNull();
|
|
expect(state.calls).toBe(1);
|
|
});
|
|
|
|
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
|
|
instantTimers();
|
|
const state = stubFetch([() => new Response("", { status: 500 })]);
|
|
|
|
await expect(apiGet("/x")).rejects.toThrow(/500/);
|
|
expect(state.calls).toBe(7);
|
|
});
|
|
|
|
test("fails fast on a connection error - no retry, per the graceful-degradation contract", async () => {
|
|
const state = { calls: 0 };
|
|
globalThis.fetch = (async () => {
|
|
state.calls++;
|
|
throw new TypeError("Unable to connect");
|
|
}) as unknown as typeof fetch;
|
|
|
|
await expect(apiGet("/x")).rejects.toThrow(/could not reach the freehire API/);
|
|
expect(state.calls).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("Retry-After", () => {
|
|
function recordWaits(): number[] {
|
|
const waits: number[] = [];
|
|
globalThis.setTimeout = ((fn: () => void, ms?: number) => {
|
|
waits.push(ms ?? 0);
|
|
return originalSetTimeout(fn, 0);
|
|
}) as unknown as typeof setTimeout;
|
|
return waits;
|
|
}
|
|
|
|
test("waits at least as long as the server asks before retrying a 429", async () => {
|
|
const waits = recordWaits();
|
|
const state = stubFetch([
|
|
() => new Response("", { status: 429, headers: { "Retry-After": "20" } }),
|
|
() => new Response('{"data":[]}', { status: 200 }),
|
|
]);
|
|
|
|
await apiGet<unknown[]>("/x");
|
|
expect(state.calls).toBe(2);
|
|
expect(waits).toEqual([20_000]);
|
|
});
|
|
|
|
test("fails at once, naming the wait, when Retry-After exceeds the retry budget", async () => {
|
|
const waits = recordWaits();
|
|
const state = stubFetch([
|
|
() => new Response("", { status: 429, headers: { "Retry-After": "40" } }),
|
|
]);
|
|
|
|
await expect(apiGet("/x")).rejects.toThrow(/retry in 40s, past this CLI's 30s retry budget/);
|
|
expect(state.calls).toBe(1);
|
|
expect(waits).toEqual([]);
|
|
});
|
|
|
|
test("bounds the TOTAL wait: repeated Retry-Afters stop once the budget is spent", async () => {
|
|
const waits = recordWaits();
|
|
const state = stubFetch([
|
|
() => new Response("", { status: 503, headers: { "Retry-After": "12" } }),
|
|
]);
|
|
|
|
await expect(apiGet("/x")).rejects.toThrow(/retry budget/);
|
|
expect(waits).toEqual([12_000, 12_000]);
|
|
expect(state.calls).toBe(3);
|
|
expect(waits.reduce((a, b) => a + b, 0)).toBeLessThanOrEqual(30_000);
|
|
});
|
|
|
|
test("its own backoff schedule alone fits inside the budget", async () => {
|
|
const waits = recordWaits();
|
|
const state = stubFetch([() => new Response("", { status: 500 })]);
|
|
|
|
await expect(apiGet("/x")).rejects.toThrow(/500/);
|
|
expect(state.calls).toBe(7);
|
|
expect(waits.reduce((a, b) => a + b, 0)).toBeLessThanOrEqual(30_000);
|
|
});
|
|
|
|
test("keeps its own backoff when the header asks for less", async () => {
|
|
const waits = recordWaits();
|
|
stubFetch([
|
|
() => new Response("", { status: 429, headers: { "Retry-After": "0" } }),
|
|
() => new Response('{"data":[]}', { status: 200 }),
|
|
]);
|
|
|
|
await apiGet<unknown[]>("/x");
|
|
expect(waits[0]).toBeGreaterThanOrEqual(500);
|
|
});
|
|
|
|
test("parses delta-seconds and HTTP-dates, ignores junk", () => {
|
|
const now = Date.parse("2026-10-18T12:00:00Z");
|
|
expect(retryAfterMs("2", now)).toBe(2_000);
|
|
expect(retryAfterMs("Sun, 18 Oct 2026 12:00:30 GMT", now)).toBe(30_000);
|
|
expect(retryAfterMs("Sun, 18 Oct 2026 11:59:00 GMT", now)).toBe(0);
|
|
expect(retryAfterMs("3600", now)).toBe(3_600_000);
|
|
expect(retryAfterMs(null, now)).toBeNull();
|
|
expect(retryAfterMs("soon", now)).toBeNull();
|
|
});
|
|
});
|