mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
test(cli): pin the 429/5xx retry contract in all six portal CLIs (#246)
* test(cli): pin the 429/5xx retry contract in all six portal CLIs The portal-skill contract requires backoff on 429/5xx, and every CLI implements it - a retry loop with exponential delay and jitter - but nothing verified the loops actually retry, stop retrying on plain 4xx, or give up after the documented attempt budget. A regression here is invisible: a CLI that stops retrying still works on every healthy request. Each CLI gains tests/retry-backoff.test.ts, network-free, using the request-timeout.test.ts pattern from #197 (import the fetch wrapper, stub globalThis.fetch): a stubbed fetch counts attempts, and a stubbed setTimeout fires immediately so the exhaustion case does not sleep through the real 500ms -> 5s/8s backoff schedule (tests run in milliseconds, not ~17s). Three assertions per fetch wrapper, adapted to each CLI's documented semantics: - a 429 is retried and the next attempt's result is returned - a plain 4xx is not retried (jobbank's fetchWithUA RETURNS the response for callers to handle - pinned as such; linkedin's htmlFetch returns "" on 404; freehire's apiGet returns null) - persistent 5xx gives up after the initial attempt plus six retries (7 fetch calls) with the status in the error freehire additionally pins its documented graceful-degradation contract: a connection failure fails fast with no retry. jobdanmark exercises both apiFetch and apiPost, which carry separate copies of the loop that could drift apart. Mutation-checked: changing maxRetries in jobindex makes the exhaustion test fail, so the tests distinguish the current behavior from a silently altered one. Verified: bun test green in all six CLIs (jobindex 19, jobnet 20, jobbank 20, jobdanmark 21, linkedin 21, freehire 31 - 0 fail); tsc --noEmit clean in all six; python3 tools/lint_skills.py OK. * test(jobindex): pin apiFetch's retry loop alongside htmlFetch's Review parity gap: jobindex carries two separate copies of the retry loop and only htmlFetch was exercised, so apiFetch's retry budget could drift silently - the same situation jobdanmark's test already handles for its apiFetch/apiPost pair. apiFetch gets the same three assertions, adapted to its documented semantics (JSON return on success, throw on plain 4xx): a 429 is retried and the next attempt's parsed body returned, a 400 is not retried, persistent 5xx gives up after the initial attempt plus six retries (7 calls). Mutation-checked on the new axis: changing apiFetch's maxRetries (the file's first copy of the loop) fails its exhaustion test while htmlFetch's tests stay green, so each wrapper is now pinned independently. Verified: bun test 30 pass / 0 fail (full jobindex suite); tsc --noEmit clean.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { apiFetch, htmlFetch } 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 -> 5s backoff schedule. apiFetch and htmlFetch carry separate copies
|
||||
// of the loop, so both are exercised to keep them from drifting apart.
|
||||
|
||||
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("htmlFetch retry/backoff", () => {
|
||||
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([
|
||||
() => new Response("", { status: 429 }),
|
||||
() => new Response("<html>ok</html>", { status: 200 }),
|
||||
]);
|
||||
|
||||
const html = await htmlFetch("https://www.jobindex.dk/x");
|
||||
expect(html).toContain("ok");
|
||||
expect(state.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("does not retry a plain 4xx", async () => {
|
||||
const state = stubFetch([() => new Response("", { status: 400 })]);
|
||||
|
||||
await expect(htmlFetch("https://www.jobindex.dk/x")).rejects.toThrow(/400/);
|
||||
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(htmlFetch("https://www.jobindex.dk/x")).rejects.toThrow(/500/);
|
||||
expect(state.calls).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiFetch retry/backoff", () => {
|
||||
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([
|
||||
() => new Response("", { status: 429 }),
|
||||
() => new Response('{"ok":true}', { status: 200 }),
|
||||
]);
|
||||
|
||||
const data = await apiFetch<{ ok: boolean }>("/x");
|
||||
expect(data.ok).toBe(true);
|
||||
expect(state.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("does not retry a plain 4xx", async () => {
|
||||
const state = stubFetch([() => new Response("", { status: 400 })]);
|
||||
|
||||
await expect(apiFetch("/x")).rejects.toThrow(/400/);
|
||||
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(apiFetch("/x")).rejects.toThrow(/500/);
|
||||
expect(state.calls).toBe(7);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user