A Response.clone() memóriacsapdája: ha az egyik olvasó lemarad

A Response.clone() nem teljes másolatot készít: a lassan fogyó streamág korlátlan memóriapuffert építhet. Mikor veszélyes ez, és hogyan kerüld el?

Egy upstream API válaszát gyakran két helyre is szeretnénk elküldeni: az egyik példány megy tovább a kliensnek, a másikat naplóznánk vagy feldolgoznánk. A Response.clone() erre szinte túlságosan kézenfekvőnek látszik. A metódus valóban létrehoz egy második Response-t, de nagy vagy lassan fogyó törzsnél rejtett memóriaköltséget hozhat be.

Két sebességgel olvasott HTTP válasz adatfolyamának memóriapuffere

A clone nem másolatot készít előre

A fetch-válasz törzse stream: egyszer lehet elfogyasztani. A klónozás ezért nem egy második, teljesen lemásolt bájttömböt gyárt. A Fetch szabvány szerint a Response klónozása a törzset tee művelettel két ReadableStreamre bontja. Ugyanez a ReadableStream.tee() viselkedése: mindkét ág ugyanazokat a chunkokat kapja.

A fontos rész a backpressure. A standard tee nem a lassabb ághoz igazodik, hanem akkor kér új adatot a forrástól, amikor a gyorsabbik ág ezt lehetővé teszi. Emiatt a lemaradó ág sorában a még el nem olvasott chunkok gyűlnek. A Streams szabvány ezt kifejezetten leírja: ha csak az egyik ág fogy, a másik belső sora elvileg korlátlanul nőhet.

Gyanús minta például, amikor const auditCopy = upstream.clone() után az eredeti válasz közvetlenül a klienshez folyik, az auditpéldány pedig egy lassú távoli sinkre vagy későbbi feldolgozásra vár. A Response.clone() dokumentációja külön figyelmeztet arra, hogy ha az egyik body sokkal gyorsabban olvasódik, a fel nem használt adat a lassabb fogyasztó memóriájába pufferelődik.

Hol fáj ez igazán?

Kis JSON-válasznál rendszerint nem számít. Fájdalmas viszont fájlletöltést közvetítő BFF-ben, képfeldolgozó API előtt, exportoknál, SSE-szerű hosszan élő válasznál és Node-os proxyban. A memória nem egyetlen kéréshez kötött globális cache: több párhuzamos lassú klón összeadódik. Különösen félrevezető, hogy lokálisan egy rövid válasszal semmi nem látszik.

Node-ban sem érdemes ezt böngészős sajátosságnak tekinteni. A futtatókörnyezet a WHATWG Web Streams API-t adja a webstreams modulban, így egy ezen alapuló fetch- vagy proxykódban ugyanazt a tee-szemantikát kell fejben tartani.

A klónozás időzítése sem közömbös. Az eredeti response törzsének olvasása után a bodyUsed igaz lesz, és onnantól már nem lehet klónt kérni. Ez helyes működés: nincs mit újra elágaztatni. A tanulság nem az, hogy minden fetch után azonnal klónozzunk, hanem az, hogy a body felhasználását előre tervezzük meg. Egyetlen fogyasztó a legegyszerűbb és legolcsóbb adatfolyam.

Mit csinálj helyette?

  • Ha csak diagnosztika kell, elsőként a státuszt, a Content-Type-ot, a Content-Length-et és egy kérésazonosítót naplózd. Ezekhez nem kell body-klón.
  • Hibaágon, amikor a választ már nem küldöd tovább, olvass ki egy szigorúan korlátozott előnézetet, majd szakítsd meg az olvasást.
  • Ha ugyanazt a teljes payloadot két rendszernek kell feldolgoznia, tárold el kontrollált helyre (például objektumtárba vagy ideiglenes fájlba), és onnan adj két olvasót. Ennek kapacitása és életciklusa tervezhető.
  • Átfolyó válasznál ne adj automatikusan második fogyasztót. Ha mégis kell, terheld és figyeld a legrosszabb méret- és sebességkülönbséggel.

Az alábbi segédfüggvény a második ponthoz való. Csak olyan response bodyra használd, amelyet utána eldobnál: legfeljebb a megadott számú bájtot tartja meg, majd cancel()-lel leállítja a streamet. A chunk itt Fetch body esetén Uint8Array.

async function readPreview(body, byteLimit = 16 * 1024) {
  const reader = body.getReader();
  const parts = [];
  let length = 0;

  try {
    while (length < byteLimit) {
      const { done, value } = await reader.read();
      if (done) break;
      const part = value.subarray(0, byteLimit - length);
      parts.push(part);
      length += part.byteLength;
    }
  } finally {
    await reader.cancel();
  }

  const joined = new Uint8Array(length);
  let offset = 0;
  for (const part of parts) {
    joined.set(part, offset);
    offset += part.byteLength;
  }
  return new TextDecoder().decode(joined);
}

A 16 KiB nem univerzális jó érték; ez egy szándékosan látható korlát. Bináris válasznál az előnézetet inkább hexben vagy base64-ben kezeld, és ne próbáld szöveggé dekódolni. A lényeg nem a konkrét limit, hanem hogy a klónozás előtt kimondjuk: ki fogyasztja a második ágat, milyen sebességgel, és mekkorára nőhet a sor.

Források

Leave a Reply

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