diff --git a/.changeset/proxyworker-per-request-fetch-failures.md b/.changeset/proxyworker-per-request-fetch-failures.md new file mode 100644 index 00000000000..836e5edb72f --- /dev/null +++ b/.changeset/proxyworker-per-request-fetch-failures.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +fix: do not exit `wrangler dev` when forwarding a single request to the Worker fails + +The dev proxy forwards every incoming request to the user Worker with `fetch()`. When that `fetch()` rejected — most commonly because the client disconnected while its request body was still being uploaded ("Network connection lost." / "Can't read from request stream because client disconnected"), or because a request the proxy had queued during startup or a reload was abandoned before it could be replayed — the rejection was reported as a fatal ProxyWorker error and the whole `wrangler dev` session exited. A rejected forward is the outcome of that one request, not a defect in the proxy, so it is now answered with a `502` and logged at debug level, and the dev session keeps running. Errors thrown while post-processing a response are still reported as before. diff --git a/packages/wrangler/e2e/dev.test.ts b/packages/wrangler/e2e/dev.test.ts index 1f785dc464e..654c4482120 100644 --- a/packages/wrangler/e2e/dev.test.ts +++ b/packages/wrangler/e2e/dev.test.ts @@ -3305,3 +3305,71 @@ describe(".env support in local dev", () => { `); }); }); + +describe("client disconnects", () => { + /** + * Opens a POST with a large announced body, sends only the first chunk and + * then destroys the connection — what a caller with a timeout does while + * uploading. workerd then fails the read of that request body. + */ + function abortUploadMidBody(url: string): Promise { + const { hostname, port } = new URL(url); + return new Promise((resolve, reject) => { + const socket = nodeNet.connect(Number(port), hostname, () => { + socket.write( + `POST /upload HTTP/1.1\r\nHost: ${hostname}:${port}\r\nContent-Type: application/octet-stream\r\nContent-Length: 2000000\r\n\r\n` + ); + socket.write(Buffer.alloc(64 * 1024), () => { + socket.destroy(); + resolve(); + }); + }); + socket.on("error", reject); + }); + } + + it("does not exit `wrangler dev` when a client aborts a request mid-body", async ({ + expect, + }) => { + const helper = new WranglerE2ETestHelper(); + await helper.seed({ + "wrangler.toml": dedent` + name = "${workerName}" + main = "src/index.ts" + compatibility_date = "2023-01-01" + `, + "src/index.ts": dedent` + export default { + async fetch(request) { + if (request.method === "POST") { + await request.text(); + } + return new Response("ok"); + } + } + `, + "package.json": dedent` + { + "name": "worker", + "version": "0.0.0", + "private": true + } + `, + }); + const worker = helper.runLongLived("wrangler dev"); + const { url } = await worker.waitForReady(); + await expect(fetchText(url)).resolves.toBe("ok"); + + for (let i = 0; i < 3; i++) { + await abortUploadMidBody(url); + await setTimeout(500); + } + + // The dev session must still be alive and serving. + await expect( + Promise.race([worker.exitCode, setTimeout(2_000, "still running")]) + ).resolves.toBe("still running"); + expect(worker.currentOutput).not.toContain("Error inside ProxyWorker"); + await expect(fetchText(url)).resolves.toBe("ok"); + }); +}); diff --git a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts index 59b507d72fe..75671d667b0 100644 --- a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts +++ b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts @@ -165,6 +165,9 @@ export class ProxyWorker implements DurableObject { // explicitly NOT await-ing this promise, we are in a loop and want to process the whole queue quickly + synchronously void fetch(userWorkerUrl, new Request(request, { headers })) + .catch((error: Error) => { + throw new UserWorkerFetchError(error); + }) .then(async (res) => { res = new Response(res.body, res); rewriteUrlRelatedHeaders(res.headers, innerUrl, outerUrl); @@ -197,6 +200,31 @@ export class ProxyWorker implements DurableObject { if ( isSameUserWorkerOrigin(userWorkerUrl, this.proxyData?.userWorkerUrl) ) { + // The fetch itself rejected, so this request never reached a + // response: the UserWorker could not be reached, or the request + // body could not be read because the client that queued it (during + // startup or a reload) has since gone away. Either way it is the + // outcome of this one request, not a defect in the ProxyWorker, so + // answer it with a 502 instead of failing the whole dev session. + if (error instanceof UserWorkerFetchError) { + void sendMessageToProxyController(this.env, { + type: "debug-log", + args: [ + "Could not proxy request to the UserWorker:", + request.method, + request.url, + error.message, + ], + }); + deferredResponse.resolve( + new Response( + `Could not proxy this request to your Worker: ${error.message}`, + { status: 502 } + ) + ); + return; + } + void sendMessageToProxyController(this.env, { type: "error", error: { @@ -284,6 +312,20 @@ function isRequestForLiveReloadWebsocket(req: Request): boolean { return isWebSocketUpgrade && websocketProtocol === LIVE_RELOAD_PROTOCOL; } +/** + * Marks a rejection of the `fetch()` to the UserWorker itself, as opposed to an + * error thrown while post-processing its response. A rejected fetch is a + * network-level outcome for a single request (UserWorker unreachable, or the + * client's request body could not be read because the client disconnected) + * and is never treated as a ProxyWorker failure. + */ +class UserWorkerFetchError extends Error { + constructor(cause: Error) { + super(cause.message, { cause }); + this.name = "UserWorkerFetchError"; + } +} + function sendMessageToProxyController( env: Env, message: ProxyWorkerOutgoingRequestBody