Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/proxyworker-per-request-fetch-failures.md
Original file line number Diff line number Diff line change
@@ -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.
68 changes: 68 additions & 0 deletions packages/wrangler/e2e/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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");
});
});
42 changes: 42 additions & 0 deletions packages/wrangler/templates/startDevWorker/ProxyWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down
Loading