A fetch timeout nem timeout: AbortSignal.timeout() a gyakorlatban

A fetchhez nincs szabványos timeout opció. Az AbortSignal.timeout() és az AbortSignal.any() a kérést is leállítja – nem csak a rá váró promise-t.

A fetch() mellett könnyű olyan opciót keresni, hogy timeout: 5000. Nincs ilyen szabványos opció. A kérés határidejét és a megszakítást az AbortSignal kezeli, ami böngészőben és a beépített fetch miatt Node.js-ben is ugyanazt a mintát adja. A lényeg nem az, hogy egy ígéretet öt másodperc után elengedjünk: a folyamatban levő műveletnek is jelzést küldünk.

Fetch kérés határidejét és leállítását bemutató sematikus ábra

A timeout legyen abort, ne verseny

A gyakori kerülőút a Promise.race([fetch(...), timeout]). Ettől a hívó valóban hamarabb kap hibát, de az eredeti HTTP-kérés tovább futhat. Egy nagyobb kliensben ez felesleges kapcsolatokat, később befutó válaszokat és nehezen követhető versenyhelyzeteket hagy maga után. Az AbortController ezzel szemben közös, szabványos leállítási csatorna: ugyanaz a signal átadható a fetch-nek és más, leállítható műveletnek is.

A legegyszerűbb határidőt a AbortSignal.timeout() adja. A metódus a megadott idő után megszakított signalt állít elő. Az időzítés aktív idő: például felfüggesztett dokumentumnál nem feltétlenül telik úgy, mint egy háttérben ketyegő setTimeout. Ez UI-kérésnél általában éppen a kívánt viselkedés.

async function loadProfile(id) {
  const response = await fetch(`/api/profiles/${id}`, {
    signal: AbortSignal.timeout(3_000),
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

A timeout itt nem HTTP-státusz. A fetch elutasított promise-szal jelez; a timeout által kiváltott ok neve általában TimeoutError. Ezzel nem érdemes minden hibát összemosni: a 500-as válasz sikeresen beérkezett HTTP-válasz, ezért az ok ellenőrzése továbbra is szükséges. A fetch dokumentációja is erre a különbségre épít.

Két leállítási ok összevonása

A valós kérésnek ritkán csak határideje van. Komponens unmountol, a felhasználó új keresést indít, vagy a szerveroldali kérés klienskapcsolata megszakad. Az AbortSignal.any() egy signalba von össze több okot: az első megszakítás lezárja a műveletet. Így a hívó dönthet egyértelműen a saját leállításáról, miközben a felső korlát is megmarad.

let activeSearch;

function startSearch(query) {
  const controller = new AbortController();
  const signal = AbortSignal.any([
    controller.signal,           // új keresés vagy unmount
    AbortSignal.timeout(1_500),  // ne várjunk korlátlanul
  ]);

  fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal })
    .then((response) => {
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return response.json();
    })
    .then(render)
    .catch((error) => {
      if (signal.aborted) console.info(signal.reason.name);
      else reportUnexpectedError(error);
    });

  return () => controller.abort(new DOMException("Elavult keresés", "AbortError"));
}

activeSearch?.();
activeSearch = startSearch(input.value);

Az any() esetén a signal.reason az elsőként abortáló signal oka; erre a DOM-szabvány is külön kitér. Saját okot ezért érdemes átadni a controller.abort(reason) hívásban. Naplózásnál hasznos, de UX-et ne kizárólag a hiba nevére építsünk: a felhasználó által indított leállítás nem hibaüzenet, a timeout viszont lehet „Próbáld újra” állapot.

Szerveroldalon a kliens eltűnése is signal

Node.js-ben ugyanez az API elérhető a globális fetch mellett; a Node dokumentációja szerint az összevont signal az első abortáló signal reason értékét örökli. Egy route handlerben a bejövő kapcsolat leállítását és a downstream szolgáltatás határidejét ugyanígy össze lehet kötni. A fontos részlet: a jelzést annak a kliensnek is át kell adni, amely tényleg dolgozik. Ha egy adatbázis-driver vagy SDK nem fogad signalt, a fetch abortálása nem fogja helyette megállítani.

A controller egyszer használatos. Abort után a hozzá tartozó signal végleg abortált marad, ezért minden új kéréshez új controllert kell létrehozni. Ugyanebből következik egy kézenfekvő szabály: ne tároljunk modulglobális controllert, és ne osszunk egyet egymástól független kérések között.

A határidő nem retry-stratégia

Az abort jó erőforrás- és állapotkezelés, de nem teszi automatikusan biztonságossá az ismétlést. Egy GET gyakran ismételhető, egy fizetést indító POST viszont lehet, hogy a szerveren már végrehajtódott, mire a kliens kifutott az időből. Ilyenkor idempotenciakulcs, állapotlekérdezés vagy alkalmazásszintű azonosító kell. A timeout csak azt mondja meg, hogy a kliens meddig vár; nem bizonyítja, hogy a távoli művelet nem történt meg.

Ha egy kliensben ma Promise.race-szal van timeout, érdemes ezt a mintát célzottan lecserélni AbortSignal.timeout()-ra. A következő lépés pedig az, hogy minden képernyő- vagy kéréséletciklusból érkező megszakítást ugyanabba a signalba fűzünk. Ettől a kód nemcsak rövidebb lesz: tényleg az a hálózati munka áll le, amelyre már nincs szükség.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük