freehire-search: honor Retry-After on 429/503 (#632)

* 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>
This commit is contained in:
authored and GitHub committed 2026-10-19 19:24:00 +02:00
1 parent fc83b3bd53
commit 43acee2b57
3 files changed
+117 -3

No files matched your search

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiGet } from "../src/helpers";
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
@@ -70,3 +70,79 @@ describe("apiGet retry/backoff", () => {
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();
});
});