Files
pikasTech-unidesk/scripts/src/platform-infra-gitea-caddy-retry.test.ts
T
2026-07-11 11:12:39 +02:00

212 lines
8.3 KiB
TypeScript

import { afterEach, expect, test } from "bun:test";
import { spawn, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs, renderGiteaWebhookCaddyHandle, validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy";
const caddyBin = process.env.CADDY_BIN;
const integrationTest = caddyBin ? test : test.skip;
const cleanup: Array<() => void | Promise<void>> = [];
afterEach(async () => {
while (cleanup.length > 0) await cleanup.pop()?.();
});
test("one YAML response budget owns Caddy retry and bridge cannot reset it", () => {
const timing = { maxRetries: 1, tryIntervalMs: 250, dialTimeoutMs: 500, writeTimeoutMs: 500 };
expect(() => validateGiteaWebhookTiming(6500, timing)).not.toThrow();
expect(() => validateGiteaWebhookTiming(9000, timing)).toThrow(/below 9000/);
expect(() => validateGiteaWebhookTiming(6500, { ...timing, maxRetries: 2 })).toThrow(/maxRetries must be 1/);
expect(deriveGiteaWebhookAttemptBudgetMs(6500, { maxRetries: 1, tryIntervalMs: 250 })).toBe(3125);
expect(deriveGiteaWebhookResponseHeaderTimeoutMs(6500, timing)).toBe(2125);
const route = renderGiteaWebhookCaddyHandle(
{ publicPath: "/hook", remotePort: 8080 },
{ enabled: true, maxBodyBytes: 64, ...timing, retryStatusCodes: [502, 503, 504] },
6500,
600,
);
expect(route).toContain("lb_try_duration 6500ms");
expect(route).toContain("dial_timeout 500ms");
expect(route).toContain("write_timeout 500ms");
expect(route).toContain("response_header_timeout 2125ms");
expect(route).toContain("request_buffers 65");
const disabled = renderGiteaWebhookCaddyHandle(
{ publicPath: "/hook", remotePort: 8080 },
{ enabled: false, maxBodyBytes: 64, ...timing, retryStatusCodes: [502, 503, 504] },
6500,
600,
);
expect(disabled).toContain("response_header_timeout 600s");
expect(disabled).not.toContain("lb_retries");
});
integrationTest("Caddy replays exact POST bytes for retryable responses and rejects oversized bodies with 413", async () => {
const version = spawnSync(caddyBin!, ["version"], { encoding: "utf8" });
expect(`${version.stdout}${version.stderr}`).toMatch(/v2\.11\./);
const backendPort = await freePort();
const caddyPort = await freePort();
let mode: "retry-once" | "client-error" | "exhaust" | "slow-budget" = "retry-once";
let hits = 0;
let bodies: Buffer[] = [];
const backendTimers = new Set<ReturnType<typeof setTimeout>>();
const backendHandler = (req: IncomingMessage, res: ServerResponse) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => {
hits += 1;
const body = Buffer.concat(chunks);
bodies.push(body);
if (mode === "slow-budget" && hits === 1) {
const timer = setTimeout(() => {
backendTimers.delete(timer);
res.writeHead(503).end("slow-retry");
}, 180);
backendTimers.add(timer);
} else if (mode === "slow-budget") {
const timer = setTimeout(() => {
backendTimers.delete(timer);
if (!res.destroyed) res.writeHead(200).end("too-late");
}, 600);
backendTimers.add(timer);
} else if (mode === "retry-once" && hits === 1) {
res.writeHead(503).end("retry");
} else if (mode === "client-error") {
res.writeHead(422).end("invalid");
} else if (mode === "exhaust") {
res.writeHead(503).end("unavailable");
} else {
res.writeHead(200, { "content-type": "application/octet-stream" }).end(body);
}
});
};
let backend = createServer(backendHandler);
await listen(backend, backendPort);
cleanup.push(() => close(backend));
const root = mkdtempSync(join(tmpdir(), "unidesk-caddy-retry-test-"));
cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const route = renderGiteaWebhookCaddyHandle(
{ publicPath: "/hook", remotePort: backendPort },
{ enabled: true, maxBodyBytes: 64, maxRetries: 1, tryIntervalMs: 50, dialTimeoutMs: 20, writeTimeoutMs: 20, retryStatusCodes: [502, 503, 504] },
500,
600,
);
const caddyfile = join(root, "Caddyfile");
writeFileSync(caddyfile, `{
admin off
auto_https off
}
http://127.0.0.1:${caddyPort} {
${route}
}
`);
const caddy = spawn(caddyBin!, ["run", "--config", caddyfile, "--adapter", "caddyfile"], { stdio: ["ignore", "pipe", "pipe"] });
const stderr: Buffer[] = [];
caddy.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
cleanup.push(async () => {
caddy.kill("SIGTERM");
await Promise.race([new Promise((resolve) => caddy.once("exit", resolve)), new Promise((resolve) => setTimeout(resolve, 1000))]);
});
await waitForCaddy(caddyPort, stderr);
const exactBody = Buffer.from(Array.from({ length: 64 }, (_, index) => index));
let response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
expect(response.status).toBe(200);
expect(Buffer.from(await response.arrayBuffer())).toEqual(exactBody);
expect(hits).toBe(2);
expect(bodies).toEqual([exactBody, exactBody]);
mode = "client-error";
hits = 0;
bodies = [];
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
expect(response.status).toBe(422);
expect(hits).toBe(1);
mode = "exhaust";
hits = 0;
bodies = [];
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
expect(response.status).toBe(503);
expect(hits).toBe(2);
expect(bodies).toEqual([exactBody, exactBody]);
hits = 0;
bodies = [];
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: Buffer.alloc(65, 0x5a) });
expect(response.status).toBe(413);
expect(hits).toBe(0);
mode = "retry-once";
hits = 1;
bodies = [];
await close(backend);
const recoveringRequest = fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
await new Promise((resolve) => setTimeout(resolve, 25));
backend = createServer(backendHandler);
await listen(backend, backendPort);
response = await recoveringRequest;
expect(response.status).toBe(200);
expect(Buffer.from(await response.arrayBuffer())).toEqual(exactBody);
expect(bodies).toEqual([exactBody]);
mode = "slow-budget";
hits = 0;
bodies = [];
const startedAt = performance.now();
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
const elapsedMs = performance.now() - startedAt;
expect([502, 504]).toContain(response.status);
expect(hits).toBe(2);
expect(elapsedMs).toBeGreaterThanOrEqual(350);
expect(elapsedMs).toBeLessThan(700);
for (const timer of backendTimers) clearTimeout(timer);
backendTimers.clear();
await close(backend);
hits = 0;
const unavailableStartedAt = performance.now();
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
const unavailableElapsedMs = performance.now() - unavailableStartedAt;
expect(response.status).toBe(502);
expect(hits).toBe(0);
expect(unavailableElapsedMs).toBeLessThan(700);
});
async function freePort(): Promise<number> {
const server = createServer();
await listen(server, 0);
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
await close(server);
return port;
}
async function listen(server: Server, port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", resolve);
});
}
async function close(server: Server): Promise<void> {
if (!server.listening) return;
await new Promise<void>((resolve) => server.close(() => resolve()));
}
async function waitForCaddy(port: number, stderr: Buffer[]): Promise<void> {
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
try {
await fetch(`http://127.0.0.1:${port}/not-a-hook`);
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 30));
}
}
throw new Error(`Caddy did not start: ${Buffer.concat(stderr).toString("utf8")}`);
}