feat: retry con backoff su errori transitori + timeoutMs applicato

- gatewayRequest: max 3 retry su 429/5xx/timeout/errore rete, backoff esponenziale + jitter, rispetta Retry-After (cap 10s)
- AbortError (annullamento utente) propagato, mai ritentato
- timeoutMs dalla config usato come fallback quando pi non fornisce signal
- testato: 503→200, sempre-503 (4 tentativi), 429+Retry-After, rete giù, abort
This commit is contained in:
Matteo Benedetto
2026-08-16 19:12:12 +02:00
parent 66e898f8b5
commit bb182dc370
+43 -8
View File
@@ -37,6 +37,14 @@ const CONFIG_DEFAULTS: MemoryConfig = {
timeoutMs: 30_000,
};
// Retry su errori transitori (429/5xx/timeout): backoff esponenziale + jitter
const MAX_RETRIES = 3;
const RETRY_BASE_MS = 500;
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function loadConfig(): MemoryConfig {
try {
return { ...CONFIG_DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) };
@@ -67,14 +75,41 @@ async function gatewayRequest(
"X-API-Key": cfg.apiKey,
};
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const res = await fetch(`${cfg.url}${route}`, {
method,
signal,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, status: res.status, data };
let lastError: unknown = null;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
// timeout fallback: se pi non fornisce un signal, usa timeoutMs dalla config
const timeoutSignal = signal ?? AbortSignal.timeout(cfg.timeoutMs ?? 30_000);
try {
const res = await fetch(`${cfg.url}${route}`, {
method,
signal: timeoutSignal,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
// retry solo su errori transitori (429/5xx), rispettando Retry-After
if ((res.status === 429 || res.status >= 500) && attempt < MAX_RETRIES) {
const retryAfter = res.headers.get("retry-after");
const delay = retryAfter
? Math.min(Number(retryAfter) * 1000 || RETRY_BASE_MS, 10_000)
: RETRY_BASE_MS * 2 ** attempt + Math.random() * 200;
await sleep(delay);
continue;
}
return { ok: res.ok, status: res.status, data };
} catch (e) {
// annullamento utente: propaga, non ritentare
if (e instanceof Error && e.name === "AbortError") throw e;
// errore di rete/timeout: retry con backoff
lastError = e;
if (attempt < MAX_RETRIES) {
await sleep(RETRY_BASE_MS * 2 ** attempt + Math.random() * 200);
continue;
}
}
}
return { ok: false, status: 0, data: { error: "network_error", detail: String(lastError ?? "unknown") } };
}
// ---------------------------------------------------------------------------