diff --git a/.changeset/web-handler-build-retry.md b/.changeset/web-handler-build-retry.md new file mode 100644 index 00000000000..e784681066b --- /dev/null +++ b/.changeset/web-handler-build-retry.md @@ -0,0 +1,15 @@ +--- +"effect": patch +--- + +Retry the layer build in `HttpEffect.toWebHandlerLayerWith` when the first request fails or aborts. + +The handlers returned by `HttpEffect.toWebHandlerLayerWith`, `HttpEffect.toWebHandlerLayer`, and +`HttpRouter.toWebHandler` build their layer lazily inside the first request and memoize the resulting +promise. On runtimes that never settle promises created inside an aborted request (e.g. Cloudflare +workerd), a client-aborted first request left the memoized promise pending forever, permanently hanging +every subsequent request in that isolate. A failed build was also memoized forever, so a transient build +error kept failing all later requests. + +The memoized build is now forgotten when it fails, or when the request that started it is aborted before +the build completes, so a later request retries the build instead of waiting on a dead promise. diff --git a/packages/effect/src/unstable/http/HttpEffect.ts b/packages/effect/src/unstable/http/HttpEffect.ts index 013e7d3f767..4b345fd1939 100644 --- a/packages/effect/src/unstable/http/HttpEffect.ts +++ b/packages/effect/src/unstable/http/HttpEffect.ts @@ -325,15 +325,34 @@ export const toWebHandlerLayerWith = < if (handlerCache) { return handlerCache(request, context) } - handlerPromise ??= Effect.runPromise(Effect.gen(function*() { - const context = yield* (options.memoMap - ? Layer.buildWithMemoMap(layer, options.memoMap, scope) - : Layer.buildWithScope(layer, scope)) - return handlerCache = toWebHandlerWith(context)( - yield* options.toHandler(context), - options.middleware - ) as any - })) + if (handlerPromise === undefined) { + const promise = handlerPromise = Effect.runPromise(Effect.gen(function*() { + const context = yield* (options.memoMap + ? Layer.buildWithMemoMap(layer, options.memoMap, scope) + : Layer.buildWithScope(layer, scope)) + return handlerCache = toWebHandlerWith(context)( + yield* options.toHandler(context), + options.middleware + ) as any + })) + // Forget the memoized build if it fails, or if the request that started + // it is aborted before the build completes, so a later request retries + // the build. On some runtimes (e.g. Cloudflare workerd) a promise created + // inside an aborted request never settles, which would otherwise leave + // every subsequent request waiting on it forever. + const forget = () => { + if (handlerCache === undefined && handlerPromise === promise) { + handlerPromise = undefined + } + } + promise.catch(forget) + if (request.signal?.aborted) { + forget() + } else { + request.signal?.addEventListener("abort", forget, { once: true }) + } + return promise.then((f) => f(request, context)) + } return handlerPromise.then((f) => f(request, context)) } return { dispose, handler: handler as any } as const diff --git a/packages/effect/test/unstable/http/HttpEffect.test.ts b/packages/effect/test/unstable/http/HttpEffect.test.ts index 544826a9526..eaa39f8ea33 100644 --- a/packages/effect/test/unstable/http/HttpEffect.test.ts +++ b/packages/effect/test/unstable/http/HttpEffect.test.ts @@ -133,6 +133,53 @@ describe("HttpEffect", () => { strictEqual(await response.text(), "420") }) + test("retries the layer build if the initiating request is aborted", async () => { + let builds = 0 + const controller = new AbortController() + const { dispose, handler } = HttpEffect.toWebHandlerLayer( + Effect.map(TestValue, (value) => HttpServerResponse.text(String(value))), + Layer.effect( + TestValue, + Effect.suspend(() => { + builds++ + // a build started before the abort never completes, simulating a + // runtime that never settles promises of aborted requests + return controller.signal.aborted ? Effect.succeed(420) : Effect.never + }) + ) + ) + // the first request starts the layer build and is then aborted + handler(new Request("http://localhost:3000/", { signal: controller.signal })).catch(() => {}) + controller.abort() + const response = await handler(new Request("http://localhost:3000/")) + strictEqual(await response.text(), "420") + strictEqual(builds, 2) + await dispose() + }) + + test("retries the layer build after a failed build", async () => { + let builds = 0 + const { dispose, handler } = HttpEffect.toWebHandlerLayer( + Effect.map(TestValue, (value) => HttpServerResponse.text(String(value))), + Layer.effect( + TestValue, + Effect.suspend(() => { + builds++ + return builds === 1 ? Effect.fail("boom" as const) : Effect.succeed(420) + }) + ) + ) + const error = await handler(new Request("http://localhost:3000/")).then( + () => "unexpected success", + (error) => error + ) + strictEqual(error, "boom") + const response = await handler(new Request("http://localhost:3000/")) + strictEqual(await response.text(), "420") + strictEqual(builds, 2) + await dispose() + }) + test("pre-response handlers are keyed by request source", () => { const request = HttpServerRequest.fromWeb(new Request("http://localhost:3000/")) const modified = request.modify({ url: "/updated" })