Den nat vores uskyldige retry-loop fik et helt API i knæ
Det dummeste er, at det startede med noget, der lignede god opførsel: “Vi må jo lige prøve igen, hvis APIet fejler.”
Jeg sad en sen aften med et lille Node-script, der skulle hente data fra et eksternt API. En enkelt request fejlede med timeout. Jeg tænkte: “Det var nok bare netværket.” Så jeg lagde et hurtigt retry-loop ind. Ingen grænser. Ingen ventetid. Bare while (fejl) { prøv igen }.
Næste morgen var APIet nede. Vores logs lignede en snydekode i et computerspil: tusindvis af requests i sekundet, alle fra os. En midlertidig fejl var på få sekunder blevet til et lille, lokalt DDoS-angreb. Jeg havde bygget en retry-maskine, ikke et robust system.
Den oplevelse var min første rigtige lektion i exponential backoff, idempotens og hvornår man ikke skal retry’e. Det er det, vi går gennem her, men i den rækkefølge jeg ville ønske, nogen havde vist mig dengang.
Hvor retry hjælper, og hvor det gør alt værre
Før vi snakker exponential backoff, er der et spørgsmål, du skal kunne svare på: “Hjælper et retry faktisk her?” Ikke teoretisk, men i din konkrete kode.
Gode scenarier for retry
Retry giver mening, når du har en rimelig forventning om, at problemet er midlertidigt og uafhængigt af inputdata. Typisk:
- Netværksglitches (timeouter, connection reset)
- Midlertidigt overbelastede systemer (HTTP 503, 504)
- Rate limiting (429), hvis du respekterer
Retry-After
Her er sandsynligheden for, at det virker i andet eller tredje forsøg, faktisk realistisk. Især hvis du giver systemet luft imellem forsøgene.
Dårlige scenarier for retry
Retry gør det værre, når fejlen skyldes noget, der ikke forsvinder af sig selv:
- Valideringsfejl (HTTP 400, 422)
- Auth-fejl (401, 403) hvor brugeren mangler rettigheder
- “Not found” (404), hvis ressourcen faktisk ikke findes
- Logiske fejl i din egen kode (null reference, constraint violation osv.)
Her er retry bare en måde at gentage en fejlbesked hurtigt og mange gange. Det svarer lidt til at råbe det samme forkert indtastede password ind i loginformen og håbe på et andet udfald.
Hvis du vil have lidt mere kontekst om overbelastning og 429, kan du med fordel læse om rate limiting i praksis. Exponential backoff spiller direkte sammen med det.
Exponential backoff forklaret med tal i stedet for teori
Exponential backoff er egentlig bare en pæn måde at sige: “Vi prøver igen, men vi venter længere og længere imellem forsøgene.”
En simpel formel, som faktisk duer i virkelige systemer, ser sådan ud:
delay = baseDelayMs * 2^(attempt - 1)
Hvis baseDelayMs = 200 (0,2 sek) og du tillader maks 5 forsøg, så får du:
- Forsøg 1: 0 ms (ingen ventetid før første forsøg)
- Forsøg 2: 200 ms
- Forsøg 3: 400 ms
- Forsøg 4: 800 ms
- Forsøg 5: 1600 ms
I alt under 3,5 sekunds ekstra ventetid. Men du har givet det andet system plads til at komme sig.
Et sæt “safe defaults” du faktisk kan bruge
Hvis du bare vil i gang uden at læse 10 blogindlæg fra cloud-leverandører, kan du bruge noget i stil med det her til klientside HTTP kald (typisk fetch/axios):
- Max forsøg: 4 eller 5
- Base delay: 200-300 ms
- Max delay: 5 sekunder
- Samlet timeout for hele operationen: 10-15 sekunder
Til baggrundsjobs / workers som ikke er bruger-facing, kan du strække det mere:
- Max forsøg: 5-8
- Base delay: 500 ms – 1 sekund
- Max delay: 30-60 sekunder
- Samlet timeout for job: afhænger af domænet, men tænk i minutter i stedet for sekunder
Pointen er: du har både et loft per forsøg og et loft for hele operationen. Ingen uendelige loops, ingen “den her request har kørt i 30 minutter og lever stadig”.
Jitter: små tilfældige hak der redder dig fra thundering herd
Hvis 100 klienter alle rammer den samme fejl på næsten samme tidspunkt, og alle bruger det samme backoff-skema, så sker der noget kedeligt: de rammer også deres retries på samme tidspunkt.
Det kaldes ofte “thundering herd”: alt vågner på samme tid og hamrer igen. Exponential backoff alene hjælper, men mønstret er stadig synkront.
Løsningen er jitter: et lille tilfældigt element oven på din delay. For eksempel:
const baseDelay = 200; // ms
const maxDelay = 5000; // ms
function getDelayWithJitter(attempt) {
const expDelay = baseDelay * 2 ** (attempt - 1);
const capped = Math.min(expDelay, maxDelay);
const jitter = Math.random() * 0.3 * capped; // op til 30% ekstra
return capped + jitter;
}
Nu vil to klienter på forsøg 3 måske vente 520 ms og 610 ms i stedet for begge at ramme præcis 400 ms. Det lyder småt, men i stor skala er det forskellen på “stille brummen” og “service nede”.
Hvilke fejl må du retry’e på? (HTTP, netværk, database)
Teori er fint, men lad os oversætte det til noget, du faktisk kan kode efter. En lille beslutningstabel.
HTTP statuskoder
Et simpelt sæt regler, du kan bruge direkte i dit HTTP-lag:
- Retry typisk: 408, 429, 500, 502, 503, 504
- Retry måske: 409 (conflict), afhænger af operation og idempotens
- Aldrig retry automatisk: 400, 401, 403, 404, 422
Hvis du får en 429, og der er en Retry-After-header, så brug den som minimum. Du kan stadig lægge din egen jitter ovenpå.
Netværksfejl og timeouts
De fleste HTTP-klienter og databiblioteker har deres egne fejltyper for timeouts og “connection reset by peer”. Dem bør du som udgangspunkt tillade retries på.
Dog kun, hvis operationen er idempotent (mere om det om lidt). En POST der kun må ske én gang, er et andet dyr end en GET.
Databasefejl
Databasefejl er mere tricky. Nogle er midlertidige, andre er klare nej’er:
- Retry typisk: deadlocks, connection drops, “too many connections”
- Ikke retry: constraint violations, syntax errors, datavalidering
Deadlocks er faktisk et klassisk eksempel på noget, hvor en retry kan løse problemet, hvis du gør det med omtanke og exponential backoff.
Idempotens: nøgleordet der afgør om du tør retry’e
Idempotens lyder tungt, men ideen er simpel: en operation er idempotent, hvis du kan køre den flere gange med samme input, og tilstanden ender ens hver gang.
Eksempler:
- Idempotent: “Sæt brugerens navn til ‘Lasse’”
- Ikke idempotent: “Overfør 500 kr. til konto X”
Det betyder ikke, at du aldrig må retry’e ikke-idempotente ting. Det betyder, at du er nødt til at bygge et lag omkring operationen, så den bliver idempotent på system-niveau.
Gør farlige operationer idempotente med et idempotency key
En klassisk teknik er at kræve en idempotency key for farlige endpoints, f.eks. betalinger eller ordreoprettelser.
Flowet:
- Klient vælger et unikt id (f.eks. en UUID) per logisk operation
- Klient sender idempotency key i header eller body på request
- Server gemmer resultatet “idempotencyKey → svar / status”
- Hvis samme key kommer igen, returnerer serveren samme svar, uden at udføre operationen igen
Nu kan klienten retry’e aggressivt ved fejl, uden at risikere dubletbetalinger.
Idempotens og workers
I kø-baserede systemer (jobs, workers) har du ofte en anden version af det samme problem: jobs, der bliver kørt flere gange.
Her kan du bruge en slags “job key”, f.eks. ordre-id eller event-id, og gemme i databasen, at den her hændelse allerede er processeret. Den tankegang går godt i spænd med alt det, vi snakker om i deployment og drift, hvor jobs, queues og retries er hverdag.
En lille “circuit breaker light” så du ikke DDoS’er dig selv
Selvom dine retries har exponential backoff og jitter, kan de stadig være for hårde ved et system, der reelt er nede.
Circuit breaker-mønstret er i fuld udgave en hel lille komponent, der holder styr på success/failure-rate over tid. Men du kan komme rigtig langt med en “light” version:
- Hold øje med hvor mange fejl du får mod et givet endpoint inden for et vindue (f.eks. 30 sekunder)
- Hvis fejlprocenten overstiger en grænse, så:
- Stop med at lave nye forsøg i X sekunder
- Eller skift til en fallback (cache, kø, besked til bruger)
Pointen er, at du hellere vil fejle hurtigt og kontrolleret, end du vil stå og hamre på et system, der allerede ligger ned.
Implementering til HTTP-kald: pseudokode med fetch/axios
Nu til noget, du kan omskrive direkte til dit eget projekt. Først en generel retry-funktion med exponential backoff og jitter.
En lille retry-helper
async function retryWithBackoff(fn, options = {}) {
const {
retries = 4,
baseDelayMs = 200,
maxDelayMs = 5000,
shouldRetry = () => true,
} = options;
let attempt = 0;
while (true) {
attempt++;
try {
const result = await fn();
return result;
} catch (err) {
const isLastAttempt = attempt >= retries;
if (isLastAttempt || !shouldRetry(err, attempt)) {
throw err;
}
const expDelay = baseDelayMs * 2 ** (attempt - 1);
const capped = Math.min(expDelay, maxDelayMs);
const jitter = Math.random() * 0.3 * capped;
const delay = capped + jitter;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
Bemærk shouldRetry. Det er her, du putter al logikken om HTTP-koder, netværksfejl osv.
Eksempel: retry på fetch med HTTP-logik
async function fetchWithRetry(url, options) {
return retryWithBackoff(async () => {
const res = await fetch(url, options);
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res;
}, {
retries: 5,
baseDelayMs: 200,
maxDelayMs: 5000,
shouldRetry: (err) => {
const status = err.status;
if (!status) {
// Netværksfejl / timeout
return true;
}
// Retry på typiske midlertidige fejl
if ([408, 429, 500, 502, 503, 504].includes(status)) {
return true;
}
return false;
},
});
}
Du kan lave noget tilsvarende med axios, hvor du i stedet bruger err.response?.status og err.code til netværksfejl.
Implementering til jobs/workers: når du har en kø
I job-systemer har du ofte to ekstra ting at tænke på:
- Du vil ikke brænde jobben ned for hurtigt, hvis en afhængighed er nede
- Du vil heller ikke lade jobs hænge for evigt i køen
Mange queues (Bull, Sidekiq, Celery osv.) har indbygget retry med backoff. Men det hjælper at forstå mekanikken, også hvis du ruller din egen mini-løsning.
En simpel worker med backoff baseret på forsøg
async function handleJob(job) {
const attempt = job.attempt || 1;
try {
await processJob(job.payload);
// markér som succes
} catch (err) {
if (!shouldRetryJob(err, attempt)) {
// send til dead letter queue / markér som permanent fejl
return;
}
const baseDelayMs = 1000;
const maxDelayMs = 60000;
const expDelay = baseDelayMs * 2 ** (attempt - 1);
const capped = Math.min(expDelay, maxDelayMs);
const jitter = Math.random() * 0.3 * capped;
const delayMs = capped + jitter;
// planlæg job til senere kørsel med forøget attempt
requeueJob({
...job,
attempt: attempt + 1,
runAt: Date.now() + delayMs,
});
}
}
Her er shouldRetryJob igen stedet, hvor du tager stilling til fejltyper og maks antal forsøg.
Observability: hvordan du opdager retry-storme i tide
Retry-strategier føles som noget, man kan sætte op én gang og så glemme. Det er en god måde at blive overrasket på kl. 03.17, når alting er rødt.
Du vil faktisk gerne kunne se, hvad dine retries laver. Hvis du har læst om at stoppe med at debugge i blinde, så ved du nok, hvor jeg vil hen her.
Metrics der er værd at måle
Et par enkle metrics gør en stor forskel:
- Antal forsøg per operation (gennemsnit og percentiler)
- Andel operationer der kræver 2+ forsøg
- Andel operationer der rammer max retries
- Latency fordelt på antal forsøg (1. forsøg vs. 2. osv.)
Hvis du pludselig ser, at 40 % af dine kald til et bestemt API skal bruge 3-4 forsøg, er det et tegn på, at noget skraber i bunden, før det braser helt sammen.
Logs der faktisk hjælper dig
Logs om retries skal være kedelige men præcise. Ingen “nu prøver vi altså lige igen 🙃”. Gem det til Slack.
Eksempel på struktureret log for et HTTP-kald:
{
"event": "http_retry",
"service": "billing-api-client",
"operation": "create_invoice",
"attempt": 3,
"max_attempts": 5,
"status": 503,
"delay_ms": 850,
"request_id": "...",
}
Det gør det langt nemmere at søge efter “alle operationer der ramte max_attempts = 5” end at læse 2000 linjer fri tekst.
Hvis du i forvejen arbejder med fejlfinding og debugging, er det et naturligt sted at koble de her logs på din eksisterende logging-strategi.
En lille beslutningsmodel du kan bruge i din egen kode
Jeg har efterhånden endt med en slags mental mini-tjekliste, hver gang jeg overvejer retries et nyt sted:
- Er operationen idempotent? Hvis nej, kan jeg gøre den idempotent med en nøgle eller deduplikering?
- Hvilke fejl er realistisk midlertidige? Lav en liste over fejltyper / HTTP-statusser.
- Hvor længe vil jeg maksimalt lade brugeren vente? Det sætter loft for samlet retries + delays.
- Hvor stor en burst kan det andet system tåle? Sæt baseDelay, maxDelay og jitter ud fra det.
- Hvordan opdager jeg, at det går galt? Hvilke metrics og logs skal jeg have på plads?
Det behøver ikke være perfekt fra dag 1. Men det slår “while (fejl) retry()” ret klart.
Min egen konklusion på exponential backoff efter et par ødelagte nætter
Jeg er endt der, hvor jeg hellere vil have en request der fejler hurtigt og tydeligt, end en der heroisk kæmper videre i 40 forsøg uden nogen stopper den. Exponential backoff med jitter, klare stopkriterier og idempotens i ryggen er for mig den rolige midtervej mellem panik og ligegyldighed.
Og ja, hver gang jeg ser et ubegrænset retry-loop i en code review, får jeg stadig en lille flashback til den nat, hvor mit “lille script” fik et helt API i knæ.









Send kommentar
Du skal være logget ind for at skrive en kommentar.