diff --git a/tests/restart-chaos/api.test.ts b/tests/restart-chaos/api.test.ts new file mode 100644 index 00000000000..35854cbea8a --- /dev/null +++ b/tests/restart-chaos/api.test.ts @@ -0,0 +1,58 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { ChaosApi, ChaosApiError, rateLimitDelayMs } from "./api.js"; + +let server: Server | null = null; +afterEach(async () => { + await new Promise(resolve => server ? server.close(() => resolve()) : resolve()); + server = null; +}); + +async function serve(handler: (count: number) => { status: number; body: unknown }) { + let count = 0; + const arrivals: number[] = []; + server = createServer((_request, response) => { + count += 1; + arrivals.push(performance.now()); + const reply = handler(count); + response.writeHead(reply.status, { "content-type": "application/json" }); + response.end(JSON.stringify(reply.body)); + }); + await new Promise(resolve => server!.listen(0, "127.0.0.1", () => resolve())); + return { url: `http://127.0.0.1:${(server!.address() as AddressInfo).port}`, count: () => count, arrivals }; +} + +describe("ChaosApi rate limits", () => { + it("retries a GET the server rate-limited, after the delay it asked for", async () => { + const target = await serve(count => count <= 2 + ? { status: 429, body: { error: "Too many concurrent issue-list requests", retryAfterSeconds: 0.05 } } + : { status: 200, body: [{ id: "c1" }] }); + await expect(new ChaosApi(target.url).get("/api/issues")).resolves.toEqual([{ id: "c1" }]); + expect(target.count()).toBe(3); + const [first, second, third] = target.arrivals; + expect(second! - first!).toBeGreaterThanOrEqual(45); + expect(third! - second!).toBeGreaterThanOrEqual(95); + }); + + it("does not repeat a write the server rate-limited", async () => { + const target = await serve(() => ({ status: 429, body: { retryAfterSeconds: 0.01 } })); + await expect(new ChaosApi(target.url).post("/api/issues/i1/comments", { body: "x" })).rejects.toBeInstanceOf(ChaosApiError); + expect(target.count()).toBe(1); + }); + + it("gives up after a bounded number of attempts", async () => { + const target = await serve(() => ({ status: 429, body: { retryAfterSeconds: 0.01 } })); + await expect(new ChaosApi(target.url, 10_000, 3).get("/api/issues")).rejects.toThrow(/returned 429/); + expect(target.count()).toBe(3); + }); + + it("reads the delay from Retry-After or the body, capped, and defaults to one second", () => { + const response = (status: number, retryAfter: string | null = null) => ({ status, headers: { get: () => retryAfter } }); + expect(rateLimitDelayMs(response(200), "")).toBeNull(); + expect(rateLimitDelayMs(response(429, "2"), "")).toBe(2_000); + expect(rateLimitDelayMs(response(429), JSON.stringify({ retryAfterSeconds: 3 }))).toBe(3_000); + expect(rateLimitDelayMs(response(429, "600"), "")).toBe(10_000); + expect(rateLimitDelayMs(response(429), "not json")).toBe(1_000); + }); +}); diff --git a/tests/restart-chaos/api.ts b/tests/restart-chaos/api.ts index 249e59cef15..b5e2fb8619d 100644 --- a/tests/restart-chaos/api.ts +++ b/tests/restart-chaos/api.ts @@ -8,10 +8,29 @@ export class ChaosApiError extends Error { constructor(readonly status: number | null, message: string) { super(message); this.name = "ChaosApiError"; } } +export function rateLimitDelayMs(response: { status: number; headers: { get(name: string): string | null } }, text: string): number | null { + if (response.status !== 429) return null; + let seconds = Number(response.headers.get("retry-after")); + if (!Number.isFinite(seconds) || seconds <= 0) { + try { seconds = Number((JSON.parse(text) as { retryAfterSeconds?: unknown }).retryAfterSeconds); } catch { seconds = NaN; } + } + return Number.isFinite(seconds) && seconds > 0 ? Math.min(10_000, seconds * 1_000) : 1_000; +} + export class ChaosApi { - constructor(readonly baseUrl: string, private readonly timeoutMs = 10_000) {} + constructor(readonly baseUrl: string, private readonly timeoutMs = 10_000, private readonly rateLimitAttempts = 5) {} private async request(method: string, route: string, body?: unknown, timeoutMs = this.timeoutMs): Promise { + for (let attempt = 1; ; attempt += 1) { + const outcome = await this.attempt(method, route, body, timeoutMs); + if (!("retryAfterMs" in outcome)) return outcome.value; + if (method !== "GET" || attempt >= this.rateLimitAttempts) throw outcome.error; + await new Promise(resolve => setTimeout(resolve, Math.min(10_000, outcome.retryAfterMs * attempt))); + } + } + + private async attempt(method: string, route: string, body: unknown, timeoutMs: number): + Promise<{ value: T } | { retryAfterMs: number; error: ChaosApiError }> { let response: Response; try { response = await fetch(`${this.baseUrl}${route}`, { @@ -25,8 +44,13 @@ export class ChaosApi { throw new ChaosApiError(null, `${method} ${route} unreachable: ${error instanceof Error ? error.message : String(error)}`); } const text = await response.text(); - if (!response.ok) throw new ChaosApiError(response.status, `${method} ${route} returned ${response.status}: ${text.slice(0, 500)}`); - return (text ? JSON.parse(text) : null) as T; + if (!response.ok) { + const error = new ChaosApiError(response.status, `${method} ${route} returned ${response.status}: ${text.slice(0, 500)}`); + const retryAfterMs = rateLimitDelayMs(response, text); + if (retryAfterMs !== null) return { retryAfterMs, error }; + throw error; + } + return { value: (text ? JSON.parse(text) : null) as T }; } get(route: string, timeoutMs?: number) { return this.request("GET", route, undefined, timeoutMs); } diff --git a/tests/restart-chaos/main.ts b/tests/restart-chaos/main.ts index 2f584a3e888..7c3ac613c10 100644 --- a/tests/restart-chaos/main.ts +++ b/tests/restart-chaos/main.ts @@ -469,7 +469,8 @@ async function main(): Promise { } await writeFile(path.join(waveDir, "faults.json"), `${JSON.stringify(faults, null, 2)}\n`, { mode: 0o600 }); const speed = await (async () => { - const childTasks = (await Promise.all(launched.map(observer => observer.childSpeedTasks()))).flat(); + const childTasks: Awaited> = []; + for (const observer of launched) childTasks.push(...await observer.childSpeedTasks()); return collectWaveSpeed(api, plan.options.env, [...launched.flatMap(observer => observer.record.issueId ? [{ issueId: observer.record.issueId, adapter: observer.plan.adapter, samples: observer.record.samples }] : []), ...childTasks], faults); })().catch(error => { timeline.add("wave_speed_failed", { error: String(error) }); anyFailure = true; return null; });