Requesting repo: gsmlg-dev/http_fetch on branch main
HTTP.fetch/2 with method: "HEAD" returns an error tuple {:error, :unexpected_streaming_message} instead of a %HTTP.Response{} with the headers.
Root cause (observed)
In lib/http.ex the streaming response handler (handle_streaming_with_headers/3 and related) matches on a fixed set of :http messages from :httpc — {:http_response, ...}, :stream, :stream_end, and :http_error. The catch-all clause throws :unexpected_streaming_message.
A HEAD response is special: :httpc delivers the status line and headers but never sends a body and never sends a :stream_end message (the body is suppressed by the HTTP spec). For the local Go test server at /head, the observed message sequence is just the status line and headers followed by nothing — the handler sits in its receive until either the body message or the timeout fires, then throws :unexpected_streaming_message.
Reproduction
Using the vendored Go test server (priv/test_server/main.go), which has a /head route that returns 200 with a Content-Type: application/json header and no body:
resp =
"http://127.0.0.1:<port>/head"
|> HTTP.fetch(method: "HEAD")
|> HTTP.Promise.await()
# Expected: %HTTP.Response{status: 200, body: "", stream: nil}
# Actual: {:error, :unexpected_streaming_message}
Impact
HTTP.fetch/2 advertises support for all seven HTTP verbs in its docstring, but HEAD is broken.
- The streaming handler in
lib/http.ex is the source of the throw, but the underlying expectation is that :httpc will eventually deliver a :stream or :stream_end, which it does not for HEAD.
Suggested direction (not requesting a specific implementation)
- In the streaming handlers in
lib/http.ex, detect the HEAD case and treat a quiescent receive after the headers (i.e. no further :http message arrives within a short window) as a successful empty body.
- Alternatively, switch HEAD to the non-streaming code path by checking the method before deciding to subscribe to stream messages.
Environment
- Elixir 1.18+
- Erlang/OTP 28
Severity
nice-to-have (HEAD is rarely used directly; most users hitting this will work around it by issuing a GET and ignoring the body)
Requesting repo: gsmlg-dev/http_fetch on branch
mainHTTP.fetch/2withmethod: "HEAD"returns an error tuple{:error, :unexpected_streaming_message}instead of a%HTTP.Response{}with the headers.Root cause (observed)
In
lib/http.exthe streaming response handler (handle_streaming_with_headers/3and related) matches on a fixed set of:httpmessages from:httpc—{:http_response, ...},:stream,:stream_end, and:http_error. The catch-all clause throws:unexpected_streaming_message.A HEAD response is special:
:httpcdelivers the status line and headers but never sends a body and never sends a:stream_endmessage (the body is suppressed by the HTTP spec). For the local Go test server at/head, the observed message sequence is just the status line and headers followed by nothing — the handler sits in itsreceiveuntil either the body message or the timeout fires, then throws:unexpected_streaming_message.Reproduction
Using the vendored Go test server (
priv/test_server/main.go), which has a/headroute that returns200with aContent-Type: application/jsonheader and no body:Impact
HTTP.fetch/2advertises support for all seven HTTP verbs in its docstring, but HEAD is broken.lib/http.exis the source of the throw, but the underlying expectation is that:httpcwill eventually deliver a:streamor:stream_end, which it does not for HEAD.Suggested direction (not requesting a specific implementation)
lib/http.ex, detect the HEAD case and treat a quiescentreceiveafter the headers (i.e. no further:httpmessage arrives within a short window) as a successful empty body.Environment
Severity
nice-to-have (HEAD is rarely used directly; most users hitting this will work around it by issuing a GET and ignoring the body)