mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-21 21:47:32 +02:00
toUTCString() truncates to whole seconds, so a +3000ms Retry-After date could mean an effective wait of ~2001ms — flaking against the 2500ms assertion floor ~1-2 in 9 runs under suite load. +4000ms puts the truncation floor at 3001ms with the assertion floor safely below it. Pulled forward from U4 because the free-tests lane is now a required check and this flake would randomly block PRs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
197 lines
7.2 KiB
TypeScript
197 lines
7.2 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
import fs from "fs";
|
|
import os from "os";
|
|
import path from "path";
|
|
import { generateVariant } from "../src/variants";
|
|
|
|
// 1x1 transparent PNG, base64 — valid bytes that fs.writeFileSync can write.
|
|
const TINY_PNG_BASE64 =
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";
|
|
|
|
function successResponse(): Response {
|
|
return new Response(
|
|
JSON.stringify({
|
|
output: [{ type: "image_generation_call", result: TINY_PNG_BASE64 }],
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
function rateLimited(retryAfter?: string): Response {
|
|
const headers: Record<string, string> = {};
|
|
if (retryAfter !== undefined) headers["Retry-After"] = retryAfter;
|
|
return new Response("rate limited", { status: 429, headers });
|
|
}
|
|
|
|
interface CallRecord {
|
|
ts: number;
|
|
}
|
|
|
|
function makeStubFetch(
|
|
responses: Response[],
|
|
calls: CallRecord[],
|
|
): typeof globalThis.fetch {
|
|
let idx = 0;
|
|
return (async (_input: any, _init?: any) => {
|
|
calls.push({ ts: Date.now() });
|
|
const response = responses[idx];
|
|
if (!response) throw new Error(`stub fetch: no response for call ${idx + 1}`);
|
|
idx++;
|
|
return response;
|
|
}) as typeof globalThis.fetch;
|
|
}
|
|
|
|
describe("generateVariant Retry-After handling", () => {
|
|
let tmpDir: string;
|
|
let outputPath: string;
|
|
let savedHome: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "variants-retry-after-"));
|
|
outputPath = path.join(tmpDir, "variant.png");
|
|
// The fetch path now writes egress receipts — keep them in the temp home.
|
|
savedHome = process.env.GSTACK_HOME;
|
|
process.env.GSTACK_HOME = tmpDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (savedHome === undefined) delete process.env.GSTACK_HOME;
|
|
else process.env.GSTACK_HOME = savedHome;
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("delta-seconds: honors Retry-After: 1 with no extra leading exponential", async () => {
|
|
const calls: CallRecord[] = [];
|
|
const fetchFn = makeStubFetch([rateLimited("1"), successResponse()], calls);
|
|
|
|
const result = await generateVariant(
|
|
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn,
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(calls.length).toBe(2);
|
|
const gap = calls[1].ts - calls[0].ts;
|
|
// Honored ~1s; should NOT add the 2s leading exponential on top
|
|
expect(gap).toBeGreaterThanOrEqual(900);
|
|
expect(gap).toBeLessThan(1700);
|
|
});
|
|
|
|
test("HTTP-date: honors a future date with no extra leading exponential", async () => {
|
|
const calls: CallRecord[] = [];
|
|
// toUTCString() truncates to whole seconds: a +3000ms date could mean an
|
|
// effective wait as low as ~2001ms, which flaked against a 2500ms floor
|
|
// under suite load (~1-2 in 9 runs — the TODOS P2 flake). +4000ms makes
|
|
// the truncation floor 3001ms; the assertion floor sits safely below it
|
|
// and the ceiling stays wide enough for a loaded scheduler.
|
|
const future = new Date(Date.now() + 4000).toUTCString();
|
|
const fetchFn = makeStubFetch([rateLimited(future), successResponse()], calls);
|
|
|
|
const result = await generateVariant(
|
|
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn,
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(calls.length).toBe(2);
|
|
const gap = calls[1].ts - calls[0].ts;
|
|
expect(gap).toBeGreaterThanOrEqual(2900);
|
|
expect(gap).toBeLessThan(5500);
|
|
});
|
|
|
|
test("invalid Retry-After (alphanumeric): falls through to exponential", async () => {
|
|
const calls: CallRecord[] = [];
|
|
const fetchFn = makeStubFetch([rateLimited("2abc"), successResponse()], calls);
|
|
|
|
const result = await generateVariant(
|
|
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn,
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(calls.length).toBe(2);
|
|
const gap = calls[1].ts - calls[0].ts;
|
|
// Falls through to existing 2s exponential leading delay
|
|
expect(gap).toBeGreaterThanOrEqual(1800);
|
|
expect(gap).toBeLessThan(3000);
|
|
});
|
|
|
|
test("no Retry-After header: falls through to exponential", async () => {
|
|
const calls: CallRecord[] = [];
|
|
const fetchFn = makeStubFetch([rateLimited(), successResponse()], calls);
|
|
|
|
const result = await generateVariant(
|
|
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn,
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(calls.length).toBe(2);
|
|
const gap = calls[1].ts - calls[0].ts;
|
|
expect(gap).toBeGreaterThanOrEqual(1800);
|
|
expect(gap).toBeLessThan(3000);
|
|
});
|
|
|
|
test("Retry-After: 0 retries immediately, skips leading exponential", async () => {
|
|
const calls: CallRecord[] = [];
|
|
const fetchFn = makeStubFetch([rateLimited("0"), successResponse()], calls);
|
|
|
|
const result = await generateVariant(
|
|
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn,
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(calls.length).toBe(2);
|
|
const gap = calls[1].ts - calls[0].ts;
|
|
expect(gap).toBeLessThan(500);
|
|
});
|
|
|
|
test("AbortError surfaces the actual configured 240s timeout in the error message", async () => {
|
|
// Regression: `generateVariant`'s `setTimeout` aborts at 240_000 ms
|
|
// (240s) but the AbortError branch returned `"Timeout (120s)"`. A
|
|
// user staring at the failure has no way to know whether to bump
|
|
// the orchestrator timeout, retry, or drop the call — the message
|
|
// is off by 2x. Force the abort path and assert the surfaced
|
|
// string matches the real bound.
|
|
const fetchFn = (async (_input: any, init?: any): Promise<Response> => {
|
|
const signal = init?.signal as AbortSignal | undefined;
|
|
return await new Promise((_resolve, reject) => {
|
|
if (signal?.aborted) {
|
|
const err = new Error("aborted");
|
|
err.name = "AbortError";
|
|
reject(err);
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", () => {
|
|
const err = new Error("aborted");
|
|
err.name = "AbortError";
|
|
reject(err);
|
|
});
|
|
});
|
|
}) as typeof globalThis.fetch;
|
|
|
|
const originalSetTimeout = globalThis.setTimeout;
|
|
// Force the 240_000 ms timer to fire on the next event-loop tick
|
|
// so the test runs in milliseconds instead of 4 minutes. Only the
|
|
// 240_000 ms timer maps to fast; the leading exponential delays
|
|
// (2_000+ ms on retry) keep their real value via this branch
|
|
// because attempt 0 never sleeps.
|
|
const fastSetTimeout = ((handler: any, timeout?: number, ...rest: any[]): any => {
|
|
if (timeout === 240_000) {
|
|
return originalSetTimeout(handler, 0, ...rest);
|
|
}
|
|
return originalSetTimeout(handler, timeout as number, ...rest);
|
|
}) as typeof globalThis.setTimeout;
|
|
(globalThis as any).setTimeout = fastSetTimeout;
|
|
|
|
try {
|
|
const result = await generateVariant(
|
|
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn,
|
|
);
|
|
|
|
expect(result.success).toBe(false);
|
|
// Critical: the message MUST report 240s (the real bound), not
|
|
// 120s (the pre-fix mismatched literal).
|
|
expect(result.error).toBe("Timeout (240s)");
|
|
} finally {
|
|
(globalThis as any).setTimeout = originalSetTimeout;
|
|
}
|
|
});
|
|
});
|