mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-08 16:07:40 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user