ShapeStream stops for good when a response body is cut off mid-read
I maintainer di solito rispondono entro 1 giorno
Valutazione
- Difficoltà
- 3/5
- Tempo stimato
- 1-2 giorni
- Idoneità per principianti
- 68/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Stack tecnologico
- node.js, playwright, typescript
- Ambito
- backend, networking
Direzione di ricerca
Start with consumeResponseBody in fetch.ts, then trace how its errors pass through createFetchWithConsumedMessages and #requestShape. Reproduce the mid-body failure with the supplied Node server script and compare it with the before-headers case. Done means a dropped body is retried with backoff from the current offset and the live case receives the second row.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
ShapeStream stops for good when a response body is cut off mid-read (unlike a network error before the headers, which is retried)
Summary
If the connection drops after the response headers arrive but before the body is fully read, ShapeStream throws FetchError (status 200) and stops. It makes no more requests. If the same connection drops before the headers, the request is retried with backoff and the stream recovers.
In both cases it's the same transient network failure, but only one of them is recoverable. Without an onError handler:
- During the initial sync, the shape never loads.
- During live mode, the stream silently stops receiving updates. The rows already received stay, but nothing new arrives.
Reproduction
A fake shape endpoint breaks one response on purpose and then answers normally. No Electric server or Postgres is needed.
npm i @electric-sql/[email protected]
node repro.mjs before-headers # control: connection dropped before the headers
node repro.mjs mid-body # initial response dropped while its body is read
node repro.mjs live-mid-body # live long-poll response dropped while its body is read
node repro.mjs mid-body onError # same as mid-body, with onError: () => ({})
repro.mjs
import http from "node:http";
import { ShapeStream } from "@electric-sql/client";
const scenario = process.argv[2] ?? "mid-body";
const withOnError = process.argv[3] === "onError";
const headers = (live) => ({
"content-type": "application/json",
"electric-handle": "handle-1",
"electric-offset": live ? "1_0" : "0_0",
"electric-schema": JSON.stringify({ id: { type: "int4", pk_index: 0 }, name: { type: "text" } }),
"electric-up-to-date": "",
...(live ? { "electric-cursor": "1" } : {}),
});
const snapshot = JSON.stringify([
{ key: `"public"."items"/"1"`, value: { id: "1", name: "first" }, headers: { operation: "insert" } },
{ headers: { control: "up-to-date" } },
]);
const liveUpdate = JSON.stringify([
{ key: `"public"."items"/"2"`, value: { id: "2", name: "second" }, headers: { operation: "insert" } },
{ headers: { control: "up-to-date" } },
]);
let requests = 0;
let liveRequests = 0;
const server = http.createServer((req, res) => {
requests++;
const live = new URL(req.url, "http://localhost").searchParams.get("live") === "true";
if (live) liveRequests++;
console.log(`server: request #${requests} ${live ? "(live)" : "(initial)"}`);
const breakThisOne =
(scenario === "before-headers" && requests === 1) ||
(scenario === "mid-body" && requests === 1) ||
(scenario === "live-mid-body" && live && liveRequests === 1);
if (breakThisOne && scenario === "before-headers") {
req.socket.destroy();
return;
}
if (breakThisOne) {
// Headers and the first byte go out, then the connection is lost
res.writeHead(200, headers(live));
res.write("[");
setTimeout(() => res.destroy(), 50);
return;
}
if (live) {
// The second live request gets an update, later ones are held open like an idle long-poll
if (liveRequests === 2) {
res.writeHead(200, headers(true));
res.end(liveUpdate);
}
return;
}
res.writeHead(200, headers(false));
res.end(snapshot);
});
server.listen(0, () => {
const { port } = server.address();
const stream = new ShapeStream({
url: `http://localhost:${port}/v1/shape`,
params: { table: "items" },
...(withOnError ? { onError: () => ({}) } : {}),
});
const rows = [];
stream.subscribe(
(messages) => {
for (const message of messages) {
if ("value" in message) rows.push(message.value.name);
}
},
(error) => console.log(`client: subscriber error -> ${error.name} status=${error.status} ${error.message}`),
);
setTimeout(() => {
console.log(`result: requests=${requests} rows=${JSON.stringify(rows)} stream.error=${stream.error ?? "none"}`);
server.closeAllConnections();
process.exit(0);
}, 4000);
});
Output
$ node repro.mjs before-headers
server: request #1 (initial)
server: request #2 (initial)
server: request #3 (live)
result: requests=3 rows=["first"] stream.error=none
$ node repro.mjs mid-body
server: request #1 (initial)
client: subscriber error -> FetchError status=200 terminated
result: requests=1 rows=[] stream.error=FetchError: terminated
$ node repro.mjs live-mid-body
server: request #1 (initial)
server: request #2 (live)
client: subscriber error -> FetchError status=200 terminated
result: requests=2 rows=["first"] stream.error=FetchError: terminated
$ node repro.mjs mid-body onError
server: request #1 (initial)
server: request #2 (initial)
server: request #3 (live)
result: requests=3 rows=["first"] stream.error=none
$ node repro.mjs live-mid-body onError
server: request #1 (initial)
server: request #2 (live)
server: request #3 (live)
server: request #4 (live)
result: requests=4 rows=["first","second"] stream.error=none
Expected: mid-body and live-mid-body behave like before-headers: the request is retried with backoff from the current offset, and in the live case the second row arrives.
Actual: the stream stops after the broken request. In live-mid-body it never makes another request, so the second row is never received.
Where it comes from
consumeResponseBody(fetch.ts) turns a failure while reading the body intonew FetchError(res.status, …).res.statusis the200of the response that broke off.createFetchWithConsumedMessageswraps the backoff client from the outside:consume(headersCheck(chunkBuffer(backoff(fetch)))). So this error never passes throughcreateFetchWithBackoff, which would retry it.- In
#requestShape, aFetchErrorthat isn't a 409 is rethrown. The comment there groups body-read failures with 400s as "not actionable without additional user input", so they go toonError. WithoutonError, the stream stops.
A body that breaks off mid-response isn't like a 400: nothing about the request needs to change, and the same request works when sent again. It's the same transient failure as a dropped connection before the headers, which the client already retries forever.
Suggestion
Retry body-read failures the same way as network errors. For example, read the body inside the backoff loop, or throw a dedicated error type from consumeResponseBody that #requestShape retries with backoff. If this behaviour is intended, it would help to say so in the onError docs, because a stream that stops silently in live mode is easy to miss.
Workaround
import { FetchError } from "@electric-sql/client";
onError: (error) => (error instanceof FetchError && error.status >= 200 && error.status < 300 ? {} : undefined)
How we ran into it
This was a flaky Playwright test in an app using @tanstack/electric-db-collection 0.6.2. When the browser navigates away, Chrome aborts every in-flight response. The body reads of shapes still in their initial sync fail with FetchError: Failed to fetch (status 200), and electric-db-collection logs "the initial sync has been marked as failed". The app renders that error state before the page is gone. The same thing happens without any navigation whenever a proxy or flaky network drops a response mid-body: the collection stays failed until the page is reloaded.
Environment
@electric-sql/client1.5.28- Node 24.14.0 (repro above) and Chromium via Playwright 1.64 (in the app)
- Lingua principale
- TypeScript
- Stelle
- 10.4k
- Fork
- 399
- Merge medio
- 22h 11m
- PR unite (30g)
- 5
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Nessun modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di electric-sql/electric
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
electric-sql/electric#4830 ·
I maintainer di solito rispondono entro 1 giorno
-
@electric-sql/client throws SecurityError on import when the browser blocks site storage (typeof localStorage outside try)Forse già presa @mohanedmoh l’ha presa 17 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
electric-sql/electric#4826 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
agents-chat-starter: spawn PUT goes to the durable-streams proxy, so no entity is created and agents never wakeForse già presa @adityavkk l’ha presa 114 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
electric-sql/electric#4687 ·
I maintainer di solito rispondono entro 1 giorno
-
Listen IP addressApertafeature request triage
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
electric-sql/electric#4150 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
ArrayDecoder doesn't handle escaped backslashes in quoted PostgreSQL array elementsForse già presa @cpruijsen l’ha presa 14 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 74/100
electric-sql/electric#3708 ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di electric-sql/electric
Issue simili
-
verified
Difficoltà 2/5 1-3 ore Idoneità per principianti 67/100
I maintainer di solito rispondono entro 5 giorni
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
anomalyco/opencode#59589 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Bug: During run init command, finish the git initialize process without question to user('Initialize a new git repository?')Forse già presa @nishu-murmu l’ha presa oggi. Apertapending-triage
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
wxt-dev/wxt#2697 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 64/100
knaw-huc/textannoviz#739 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 74/100
I maintainer di solito rispondono entro 1 giorno