Skip to content

[internal] HTTP.AbortController.abort/1 cannot cancel in-flight requests in OTP 28 #6

Description

@gsmlg

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:

  1. 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.
  2. 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.
  3. 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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    internal requestInternal request originating from another repo in the gsmlg org

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions