Requesting repo: gsmlg-dev/http_fetch on branch main
Summary
HTTP.AbortController.abort/1 is documented as a way to cancel an in-flight HTTP request, but on Erlang/OTP 28 the abort signal does not actually interrupt the request. The awaiting HTTP.Promise still resolves to a normal HTTP.Response instead of {:error, reason}.
Root cause (observed)
In lib/http/abort_controller.ex (abort/1), the controller calls:
:httpc.cancel_request(state.request_id)
This was meant to cancel the in-flight :httpc request started by HTTP.fetch/2 (lib/http.ex ~line 470, where the request_id is wired up via HTTP.AbortController.set_request_id/2). However, on OTP 28 the :httpc request keeps running to completion in the background — cancel_request/1 does not return an error to the awaiting caller. The cancellation appears to be a no-op (or, at best, a partial effect limited to connection-level cleanup that does not propagate back to the Task doing :httpc.request/4 with sync: false).
The docstring on HTTP.AbortController describes aborting as the intended user-facing behavior ("... The awaiting Promise will receive an error result ..."), and there is an e2e test in e2e/error_test.exs covering this exact scenario (currently tagged :skip for this reason).
Reproduction
controller = HTTP.AbortController.new()
promise =
"http://127.0.0.1:<port>/delay/2000"
|> HTTP.fetch(
signal: controller,
options: [timeout: 10_000]
)
Process.sleep(100)
:ok = HTTP.AbortController.abort(controller)
result = HTTP.Promise.await(promise, 10_000)
# Expected: {:error, _}
# Actual: %HTTP.Response{...} with status 200 and the full body
Using the local Go test server in priv/test_server/main.go (the same server the e2e suite uses). The /delay/<ms> route blocks for the given number of milliseconds before returning 200.
Impact
HTTP.fetch/2 advertises cancelable requests via signal: controller, but the contract is not honored.
- Callers that rely on
HTTP.Promise.await/2 returning {:error, _} to release resources or fall back will instead receive a full HTTP.Response and silently complete the request.
- This breaks any user code that uses
AbortController to enforce a wall-clock deadline shorter than the server's response time.
Suggested direction (not requesting a specific implementation)
A few possible angles — happy to discuss:
- Have
HTTP.fetch/2 itself poll HTTP.AbortController.aborted?/1 after the request returns and convert a "completed after abort" response into {:error, :aborted}. This is a thin layer over the existing :httpc behavior and is probably the smallest change.
- Wrap the in-flight work in a supervised
Task that monitors the controller and :httpc.cancel_request/1s + returns {:error, :aborted} when triggered, so the awaiter is notified promptly rather than after the full request completes.
- Replace the
:httpc backend for abortable requests with one that supports mid-flight cancellation (e.g. :hackney or :finch). Bigger change.
Option 1 is the lowest-risk fix and would at least make the public contract honest.
Environment
- Elixir 1.18+
- Erlang/OTP 28
Severity
nice-to-have (the abort API exists and the controller state machine works, but the cross-process cancellation does not deliver the documented behavior)
Requesting repo: gsmlg-dev/http_fetch on branch
mainSummary
HTTP.AbortController.abort/1is documented as a way to cancel an in-flight HTTP request, but on Erlang/OTP 28 the abort signal does not actually interrupt the request. The awaitingHTTP.Promisestill resolves to a normalHTTP.Responseinstead of{:error, reason}.Root cause (observed)
In
lib/http/abort_controller.ex(abort/1), the controller calls:This was meant to cancel the in-flight
:httpcrequest started byHTTP.fetch/2(lib/http.ex~line 470, where the request_id is wired up viaHTTP.AbortController.set_request_id/2). However, on OTP 28 the:httpcrequest keeps running to completion in the background —cancel_request/1does not return an error to the awaiting caller. The cancellation appears to be a no-op (or, at best, a partial effect limited to connection-level cleanup that does not propagate back to theTaskdoing:httpc.request/4withsync: false).The docstring on
HTTP.AbortControllerdescribes aborting as the intended user-facing behavior ("... The awaiting Promise will receive an error result ..."), and there is an e2e test ine2e/error_test.exscovering this exact scenario (currently tagged:skipfor this reason).Reproduction
Using the local Go test server in
priv/test_server/main.go(the same server the e2e suite uses). The/delay/<ms>route blocks for the given number of milliseconds before returning200.Impact
HTTP.fetch/2advertises cancelable requests viasignal: controller, but the contract is not honored.HTTP.Promise.await/2returning{:error, _}to release resources or fall back will instead receive a fullHTTP.Responseand silently complete the request.AbortControllerto enforce a wall-clock deadline shorter than the server's response time.Suggested direction (not requesting a specific implementation)
A few possible angles — happy to discuss:
HTTP.fetch/2itself pollHTTP.AbortController.aborted?/1after the request returns and convert a "completed after abort" response into{:error, :aborted}. This is a thin layer over the existing:httpcbehavior and is probably the smallest change.Taskthat monitors the controller and:httpc.cancel_request/1s + returns{:error, :aborted}when triggered, so the awaiter is notified promptly rather than after the full request completes.:httpcbackend for abortable requests with one that supports mid-flight cancellation (e.g.:hackneyor:finch). Bigger change.Option 1 is the lowest-risk fix and would at least make the public contract honest.
Environment
Severity
nice-to-have (the abort API exists and the controller state machine works, but the cross-process cancellation does not deliver the documented behavior)