HTTP.Response (http_fetch v0.21.18)

Copy Markdown View Source

HTTP response struct implementing the Browser Fetch API Response interface.

This module represents an HTTP response with full compatibility with the Browser Fetch API standard. It supports both buffered and streaming responses, body consumption tracking, response cloning, and multiple read formats.

Browser Fetch API Compatibility

This module implements the JavaScript Fetch API Response interface:

  • status - HTTP status code (e.g., 200, 404, 500)
  • status_text - Status message ("OK", "Not Found", etc.)
  • ok - Boolean for success status (200-299)
  • headers - Response headers as HTTP.Headers struct
  • body - Response body as binary, or stream PID for streaming responses
  • body_used - Track if body has been consumed
  • url - The requested URL as URI struct
  • redirected - Whether response was redirected
  • type - Response type (:basic, :cors, :error, :opaque)
  • stream - Stream process PID for streaming responses (nil for buffered)

Network responses expose the actual http_version (:http1, :http2, or :http3). HTTP/2 and HTTP/3 retain ordered {status, headers} informational blocks in informational when the final response arrives. Buffered trailers are available in trailers when the promise completes, separate from initial headers. Streamed trailers arrive as {:stream_trailers, stream_pid, headers} after body delivery acknowledgements and immediately before {:stream_end, stream_pid}; the immutable response's trailers field remains empty.

HTTP/2 retains at most 128 informational blocks and 65,536 bytes of informational fields in total. Trailers have at most 256 fields and 65,536 bytes. These byte budgets include 32 bytes of overhead per field, and the protocol's HPACK limits also apply. Exceeding a retention budget fails with :http2_informational_limit or :http2_trailers_limit rather than dropping fields. HTTP/1 informational fields are currently not exposed. HTTP/1 trailers retain at most 128 fields and 65,536 serialized bytes, including delimiters, and reject malformed or forbidden fields. Permitted undeclared response trailers are accepted; Trailer declarations are advisory and validated. Exposing metadata does not establish gRPC support.

HTTP Content-Encoding

Network responses transparently decode gzip (including concatenated members) and zlib-wrapped deflate on HTTP/1.1, HTTP/2 and HTTP/3. Supported coding chains and repeated header fields decode in reverse order; identity is a no-op. If any coding is unsupported (for example br), the entire body passes through unchanged. No Accept-Encoding header is added automatically; callers may explicitly request gzip, deflate.

Decoding applies only to Content-Encoding. A .tgz download without that header keeps its original archive bytes, regardless of URL or Content-Type. Manually constructed responses are also unchanged. HEAD and body-forbidden statuses expose their empty body without decoding.

All network body consumers (text/1, json/1, read_all/1, array_buffer/1, blob/1, clone/1 and write_to/2) see decoded bytes, as do direct stream readers. Stream decoding is incremental and retains delivery acknowledgements and trailer ordering. A response stream is consumed once; buffered bodies can be read repeatedly. Chunk boundaries may change during decoding.

Initial headers retain the server's values, including Content-Encoding and the encoded Content-Length. That length also determines whether a response is buffered or streamed; it is not the decoded byte count. Streaming telemetry chunk/stop byte counts describe the decoded bytes, while streaming start retains the encoded length. Decoded bodies can be larger than encoded bodies.

Malformed or truncated supported encodings fail buffered fetches with {:error, {:invalid_content_encoding, coding}}. Streams deliver {:stream_error, stream_pid, {:invalid_content_encoding, coding}}; write_to/2 returns that error, while read methods raise the usual stream-read RuntimeError. A failed file write may leave partial decoded bytes. Raw (non-zlib-wrapped) deflate is rejected as malformed.

Response Methods

Elixir-Specific Differences

Immutability: Unlike JavaScript, Elixir responses are immutable. The body_used property won't automatically update across function calls. Use clone/1 before multiple reads.

Synchronous Returns: Methods like json() and text() return values directly instead of Promises, following Elixir conventions.

Stream Handling: Large responses expose a stream process in body, with stream retained as a compatibility alias.

Struct Fields

  • status - HTTP status code (e.g., 200, 404, 500)
  • status_text - Status message (e.g., "OK", "Not Found")
  • ok - Boolean indicating success (true for 200-299)
  • headers - Response headers as HTTP.Headers struct
  • body - Response body as binary, or stream PID for streaming responses
  • body_used - Whether body has been consumed (Browser API behavior)
  • url - The requested URL as URI struct
  • redirected - Whether response was redirected
  • type - Response type (:basic, :cors, :error, :opaque)
  • stream - Stream process PID for streaming responses (nil for buffered)

Streaming vs Buffered Responses

Responses are automatically streamed when:

  • Content-Length > 5MB
  • Content-Length header is missing/unknown

Buffered responses have the complete body in the body field:

%HTTP.Response{
  status: 200,
  body: "response data",
  stream: nil
}

Streaming responses expose the stream PID on body, with the same PID retained on stream for compatibility:

  %HTTP.Response{
    status: 200,
    body: #PID<0.123.0>,
    stream: #PID<0.123.0>
  }

Usage

# Simple text response
response = HTTP.fetch("https://example.com") |> HTTP.Promise.await()
text = HTTP.Response.text(response)

# JSON parsing
response = HTTP.fetch("https://api.example.com/data") |> HTTP.Promise.await()
{:ok, json} = HTTP.Response.json(response)

# Write to file (works with both streaming and buffered)
response = HTTP.fetch("https://example.com/file.zip") |> HTTP.Promise.await()
:ok = HTTP.Response.write_to(response, "/tmp/file.zip")

# Get specific header
content_type = HTTP.Response.get_header(response, "content-type")

# Parse Content-Type
{media_type, params} = HTTP.Response.content_type(response)

Streaming Responses

For streaming responses, use read_all/1 or write_to/2 to consume the stream:

# Read entire stream into memory
body = HTTP.Response.read_all(response)

# Write stream directly to file (more memory efficient)
:ok = HTTP.Response.write_to(response, "/path/to/file")

Summary

Functions

Alias for arrayBuffer/1 following Elixir naming conventions.

Reads the response body as raw binary data (equivalent to JavaScript's ArrayBuffer).

Reads the response body as a Blob (binary data with metadata).

Creates a duplicate of the response, allowing the body to be read multiple times.

Parses the Content-Type header to extract media type and parameters.

Gets a response header value by name (case-insensitive).

Parses the response body as JSON using Elixir's built-in JSON module (available in Elixir 1.18+).

Creates a new Response struct with Browser Fetch API fields populated.

Reads the entire response body as binary.

Reads the response body and parses it as JSON.

Reads the response body as text.

Writes the response body to a file.

Types

response_type()

@type response_type() :: :basic | :cors | :error | :opaque

t()

@type t() :: %HTTP.Response{
  body: binary() | pid() | nil,
  body_used: boolean(),
  headers: HTTP.Headers.t(),
  http_version: :http1 | :http2 | :http3 | nil,
  informational: [{integer(), HTTP.Headers.t()}],
  ok: boolean(),
  redirected: boolean(),
  response_stream_idle_timeout: pos_integer() | :infinity | nil,
  status: integer(),
  status_text: String.t(),
  stream: pid() | nil,
  trailers: HTTP.Headers.t(),
  type: response_type(),
  url: URI.t() | nil
}

Functions

array_buffer(response)

@spec array_buffer(t()) :: binary()

Alias for arrayBuffer/1 following Elixir naming conventions.

arrayBuffer(response)

@spec arrayBuffer(t()) :: binary()

Reads the response body as raw binary data (equivalent to JavaScript's ArrayBuffer).

Returns the body as an Elixir binary. For streaming responses, this reads the entire stream into memory.

Examples

iex> response = HTTP.Response.new(status: 200, body: <<1, 2, 3, 4>>)
iex> HTTP.Response.arrayBuffer(response)
<<1, 2, 3, 4>>

blob(response)

@spec blob(t()) :: HTTP.Blob.t()

Reads the response body as a Blob (binary data with metadata).

Returns an HTTP.Blob struct containing the body data, MIME type extracted from the Content-Type header, and size in bytes.

Examples

iex> response = HTTP.Response.new(
...>   status: 200,
...>   body: <<1, 2, 3, 4>>,
...>   headers: HTTP.Headers.new([{"content-type", "image/png"}])
...> )
iex> blob = HTTP.Response.blob(response)
iex> blob.type
"image/png"
iex> blob.size
4

clone(response)

@spec clone(t()) :: t()

Creates a duplicate of the response, allowing the body to be read multiple times.

For buffered responses, this creates a shallow copy with the body duplicated and body_used reset to false.

For streaming responses, this creates a "tee" that splits the stream into two independent streams that can be consumed separately.

Examples

# Clone buffered response
response = HTTP.Response.new(status: 200, body: "data")
clone = HTTP.Response.clone(response)

# Read original
text1 = HTTP.Response.text(response)

# Read clone independently
text2 = HTTP.Response.text(clone)

# Both contain the same data
text1 == text2  # true

# Clone streaming response
response = HTTP.Response.new(status: 200, stream: stream_pid)
clone = HTTP.Response.clone(response)

# Both can be read independently
data1 = HTTP.Response.read_all(response)
data2 = HTTP.Response.read_all(clone)

content_type(response)

@spec content_type(t()) :: {String.t(), map()}

Parses the Content-Type header to extract media type and parameters.

Examples

iex> response = %HTTP.Response{headers: HTTP.Headers.new([{"Content-Type", "application/json; charset=utf-8"}])}
iex> HTTP.Response.content_type(response)
{"application/json", %{"charset" => "utf-8"}}

iex> response = %HTTP.Response{headers: HTTP.Headers.new([{"Content-Type", "text/plain"}])}
iex> HTTP.Response.content_type(response)
{"text/plain", %{}}

get_header(response, name)

@spec get_header(t(), String.t()) :: String.t() | nil

Gets a response header value by name (case-insensitive).

Examples

iex> response = %HTTP.Response{headers: HTTP.Headers.new([{"Content-Type", "application/json"}])}
iex> HTTP.Response.get_header(response, "content-type")
"application/json"

iex> response = %HTTP.Response{headers: HTTP.Headers.new([{"Content-Type", "application/json"}])}
iex> HTTP.Response.get_header(response, "missing")
nil

json(response)

@spec json(t()) :: {:ok, map() | list()} | {:error, term()}

Parses the response body as JSON using Elixir's built-in JSON module (available in Elixir 1.18+).

Returns:

  • {:ok, map | list} if the body is valid JSON.

  • {:error, reason} if the body cannot be parsed as JSON.

Note: This method is deprecated in favor of read_as_json/1 for streaming responses.

new(opts \\ [])

@spec new(keyword()) :: t()

Creates a new Response struct with Browser Fetch API fields populated.

This is the recommended way to create Response structs. It automatically derives status_text and ok from the status code, and sets proper defaults for all Browser API fields.

Parameters

  • opts - Keyword list with fields:
    • :status - HTTP status code (default: 0)
    • :headers - HTTP.Headers struct (default: empty headers)
    • :body - Response body binary or stream PID (default: stream PID or nil)
    • :url - Request URL (default: nil)
    • :stream - Stream PID for streaming responses (default: nil)
    • :redirected - Whether response was redirected (default: false)
    • :type - Response type (default: :basic)

The following fields are automatically computed:

  • status_text - Derived from status code via HTTP.StatusText
  • ok - Set to true if status in 200..299
  • body_used - Always initialized to false

Examples

iex> response = HTTP.Response.new(status: 200, body: "OK", url: URI.parse("https://example.com"))
iex> response.status
200
iex> response.status_text
"OK"
iex> response.ok
true
iex> response.body
"OK"
iex> response.body_used
false

iex> response = HTTP.Response.new(status: 404, headers: HTTP.Headers.new())
iex> response.status
404
iex> response.status_text
"Not Found"
iex> response.ok
false

read_all(response)

@spec read_all(t()) :: binary()

Reads the entire response body as binary.

For streaming responses, this will consume the entire stream into memory. For non-streaming responses, returns the existing body.

Examples

iex> response = HTTP.Response.new(status: 200, body: "Hello World")
iex> HTTP.Response.read_all(response)
"Hello World"

read_as_json(response)

@spec read_as_json(t()) :: {:ok, map() | list()} | {:error, term()}

Reads the response body and parses it as JSON.

For streaming responses, this will read the entire stream before parsing.

Returns:

  • {:ok, map | list} if the body is valid JSON.

  • {:error, reason} if the body cannot be parsed as JSON.

Examples

iex> response = HTTP.Response.new(status: 200, body: ~s({"key": "value"}))
iex> HTTP.Response.read_as_json(response)
{:ok, %{"key" => "value"}}

text(response)

@spec text(t()) :: binary()

Reads the response body as text.

For streaming responses, this will read the entire stream into memory.

Note: Due to Elixir's immutability, the body_used field exists for API compatibility but doesn't prevent multiple reads. Use clone/1 for clarity when reading multiple times.

Examples

iex> response = HTTP.Response.new(status: 200, body: "Hello")
iex> HTTP.Response.text(response)
"Hello"

write_to(response, file_path)

@spec write_to(t(), String.t()) :: :ok | {:error, term()}

Writes the response body to a file.

For streaming responses, this writes chunks directly to the file. For non-streaming responses, it will write the existing body directly.

Parameters

  • response: The HTTP response to write
  • file_path: The path to write the file to

Returns

  • :ok on success
  • {:error, reason} on failure

Examples

iex> response = %HTTP.Response{body: "file content", stream: nil}
iex> HTTP.Response.write_to(response, "/tmp/test.txt")
:ok