HTTP (http_fetch v0.8.0)

A browser-like HTTP fetch API for Elixir, built on Erlang's :httpc module.

This module provides a modern, Promise-based HTTP client interface similar to the browser's fetch() API. It supports asynchronous requests, streaming, request cancellation, and comprehensive telemetry integration.

Features

  • Async by default: All requests use Task.Supervisor with async_nolink/4
  • Automatic streaming: Responses >5MB or with unknown Content-Length automatically stream
  • Request cancellation: Via HTTP.AbortController for aborting in-flight requests
  • Promise chaining: JavaScript-like promise interface with then/3 support
  • Unix Domain Sockets: Support for HTTP over Unix sockets (Docker daemon, systemd, etc.)
  • Telemetry integration: Comprehensive event emission for monitoring and observability
  • Zero external dependencies: Uses only Erlang/OTP built-in modules (except telemetry)

Quick Start

# Simple GET request
{:ok, response} =
  HTTP.fetch("https://jsonplaceholder.typicode.com/posts/1")
  |> HTTP.Promise.await()

# Parse JSON response
{:ok, json} = HTTP.Response.json(response)

# POST with JSON body
{:ok, response} =
  HTTP.fetch("https://api.example.com/posts", [
    method: "POST",
    headers: %{"Content-Type" => "application/json"},
    body: JSON.encode!(%{title: "Hello", body: "World"})
  ])
  |> HTTP.Promise.await()

# Unix Domain Socket request (e.g., Docker daemon)
{:ok, response} =
  HTTP.fetch("http://localhost/version",
    unix_socket: "/var/run/docker.sock")
  |> HTTP.Promise.await()

Architecture

The library is structured around these core modules:

Streaming Behavior

Responses are automatically streamed when:

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

Streaming responses have body: nil and stream: pid in the Response struct. Use HTTP.Response.read_all/1 or HTTP.Response.write_to/2 to consume streams.

Telemetry Events

All events use the [:http_fetch, ...] prefix:

  • [:http_fetch, :request, :start] - Request initiated
  • [:http_fetch, :request, :stop] - Request completed
  • [:http_fetch, :request, :exception] - Request failed
  • [:http_fetch, :streaming, :start] - Streaming started
  • [:http_fetch, :streaming, :chunk] - Stream chunk received
  • [:http_fetch, :streaming, :stop] - Streaming completed

See HTTP.Telemetry for detailed event documentation.

Summary

Functions

Performs an HTTP request, similar to global.fetch in web browsers. Uses Erlang's built-in :httpc module asynchronously (sync: false).

Types

httpc_response_tuple()

@type httpc_response_tuple() ::
  {:ok, pid()}
  | {:error, term()}
  | {{:http_version, integer(), String.t()},
     [{atom() | String.t(), String.t()}], binary()}

Functions

fetch(url, init \\ [])

@spec fetch(String.t() | URI.t(), Keyword.t() | map()) :: %HTTP.Promise{task: term()}

Performs an HTTP request, similar to global.fetch in web browsers. Uses Erlang's built-in :httpc module asynchronously (sync: false).

Arguments:

  • url: The URL to fetch (string or URI struct).
  • init: An optional keyword list or map of options for the request.
        Supported options:
          - `:method`: The HTTP method (e.g., "GET", "POST"). Defaults to "GET".
                       Can be a string or an atom (e.g., "GET" or :get).
          - `:headers`: A list of request headers as `{name, value}` tuples (e.g., [{"Content-Type", "application/json"}])
                        or a map that will be converted to the tuple format.
          - `:body`: The request body (should be a binary or a string that can be coerced to binary).
          - `:content_type`: The Content-Type header value. If not provided for methods with body,
                             defaults to "application/octet-stream" in `Request.to_httpc_args`.
          - `:options`: A keyword list of options directly passed as the 3rd argument to `:httpc.request`
                        (e.g., `timeout: 10_000`, `connect_timeout: 5_000`).
          - `:client_opts`: A keyword list of options directly passed as the 4th argument to `:httpc.request`
                            (e.g., `sync: false`, `body_format: :binary`). Overrides `Request` defaults.
          - `:signal`: An `HTTP.AbortController` PID. If provided, the request can be aborted
                       via this controller.
          - `:unix_socket`: Path to a Unix Domain Socket file (e.g., "/var/run/docker.sock").
                            When provided, the request is sent over the Unix socket instead of TCP/IP.

Returns:

  • %HTTP.Promise{}: A Promise struct. The caller should HTTP.Promise.await(promise_struct) to get the final
           `%HTTP.Response{}` or `{:error, reason}`. If the request cannot be initiated
           (e.g., invalid URL, bad arguments), the Promise will contain an error result
           when awaited.

Example Usage:

# GET request and awaiting JSON
promise_json = HTTP.fetch("https://jsonplaceholder.typicode.com/todos/1")
case HTTP.Promise.await(promise_json) do
  %HTTP.Response{} = response ->
    case HTTP.Response.json(response) do
      {:ok, json_body} ->
        IO.puts "GET JSON successful! Title: #{json_body["title"]}"
      {:error, reason} ->
        IO.inspect reason, label: "JSON Parse Error"
    end
  {:error, reason} ->
    IO.inspect reason, label: "GET Error for JSON"
end

# GET request and awaiting text
promise_text = HTTP.fetch("https://jsonplaceholder.typicode.com/posts/1")
case HTTP.Promise.await(promise_text) do
  %HTTP.Response{} = response ->
    text_body = HTTP.Response.text(response)
    IO.puts "GET Text successful! First 50 chars: #{String.slice(text_body, 0, 50)}..."
  {:error, reason} ->
    IO.inspect reason, label: "GET Error for Text"
end

# Simple Promise chaining: fetch -> parse JSON -> print title
HTTP.fetch("https://jsonplaceholder.typicode.com/todos/2")
|> HTTP.Promise.then(fn %HTTP.Response{} = response ->
  HTTP.Response.json(response)
end)
|> HTTP.Promise.then(fn {:ok, json_body} ->
  IO.puts "Chained JSON successful! Title: #{json_body["title"]}"
end)
|> HTTP.Promise.await() # Await the final chained promise to ensure execution

# Promise chaining with error handling: fetch -> parse JSON -> handle success or error
HTTP.fetch("https://jsonplaceholder.typicode.com/nonexistent") # This URL will cause an error
|> HTTP.Promise.then(
  fn %HTTP.Response{} = response ->
    IO.puts "This success branch should not be called for a 404!"
    HTTP.Response.json(response)
  end,
  fn reason ->
    IO.inspect reason, label: "Chained Error Handler Caught"
    {:error, :handled_error} # Return an error tuple to propagate rejection
  end
)
|> HTTP.Promise.await() # Await the final chained promise

# Chaining where a callback returns another promise
HTTP.fetch("https://jsonplaceholder.typicode.com/posts/1")
|> HTTP.Promise.then(fn %HTTP.Response{} = response ->
  # Simulate fetching comments for the post
  post_id = case HTTP.Response.json(response) do
    {:ok, %{"id" => id}} -> id
    _ -> nil
  end
  if post_id do
    IO.puts "Fetched post #{post_id}. Now fetching comments..."
    HTTP.fetch("https://jsonplaceholder.typicode.com/posts/#{post_id}/comments")
  else
    # If post_id is nil, we want to reject this branch of the promise chain
    throw {:error, :post_id_not_found}
  end
end)
|> HTTP.Promise.then(fn comments_response ->
  case HTTP.Response.json(comments_response) do
    {:ok, comments} ->
      IO.puts "Successfully fetched #{length(comments)} comments for the post."
    {:error, reason} ->
      IO.inspect reason, label: "Failed to parse comments JSON"
  end
end)
|> HTTP.Promise.await()


# POST request with JSON body and headers
# If you're using Elixir 1.18+, JSON.encode! is built-in. Otherwise, you'd need a library like Poison.
# promise_post = HTTP.fetch("https://jsonplaceholder.typicode.com/posts",
#        method: "POST",
#        headers: [{"Accept", "application/json"}],
#        content_type: "application/json",
#        body: JSON.encode!(%{title: "foo", body: "bar", userId: 1})
#      )
# case HTTP.Promise.await(promise_post) do
#   {:ok, %HTTP.Response{status: 201, body: body}} ->
#     IO.puts "POST successful! Body: #{body}"
#   {:error, reason} ->
#     IO.inspect reason, label: "POST Error"
# end

# Request with custom :httpc options (e.g., longer timeout for request options)
delayed_promise = HTTP.fetch("https://httpbin.org/delay/5", options: [timeout: 10_000])
case HTTP.Promise.await(delayed_promise) do
  %HTTP.Response{status: status} ->
    IO.puts "Delayed request successful! Status: #{status}"
  {:error, reason} ->
    IO.inspect reason, label: "Delayed Request Result"
end

# Abortable request example
controller = HTTP.AbortController.new() # Create a new controller
IO.puts "Fetching a long request that will be aborted..."
abortable_promise = HTTP.fetch("https://httpbin.org/delay/10", signal: controller, options: [timeout: 20_000])

# Simulate some work, then abort after a short delay
Task.start_link(fn ->
  :timer.sleep(2000) # Wait 2 seconds
  IO.puts "Attempting to abort the request after 2 seconds..."
  HTTP.AbortController.abort(controller)
end)

# Await the result of the abortable promise
case HTTP.Promise.await(abortable_promise) do
  %HTTP.Response{status: status} ->
    IO.puts "Abortable request completed successfully! Status: #{status}"
  {:error, reason} ->
    IO.inspect reason, label: "Abortable Request Result"
    if reason == :econnrefused or reason == :nxdomain do # Common errors for aborted :httpc requests
      IO.puts "Request was likely aborted. Reason: #{inspect(reason)}"
    end
end

# Unix Domain Socket request to Docker daemon
docker_promise = HTTP.fetch("http://localhost/version", unix_socket: "/var/run/docker.sock")
case HTTP.Promise.await(docker_promise) do
  %HTTP.Response{status: 200} = response ->
    case HTTP.Response.json(response) do
      {:ok, json} ->
        IO.puts "Docker Version: #{json["Version"]}"
        IO.puts "API Version: #{json["ApiVersion"]}"
      {:error, reason} ->
        IO.inspect reason, label: "JSON Parse Error"
    end
  {:error, reason} ->
    IO.inspect reason, label: "Docker Request Error"
end