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
15 changes: 15 additions & 0 deletions .changeset/web-handler-build-retry.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 28 additions & 9 deletions packages/effect/src/unstable/http/HttpEffect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Provided, R>(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<Provided, R>(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
Expand Down
47 changes: 47 additions & 0 deletions packages/effect/test/unstable/http/HttpEffect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down