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.AbortControllerfor aborting in-flight requests - Promise chaining: JavaScript-like promise interface with
then/3support - 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:
HTTP- Main entry point with thefetch/2functionHTTP.Promise- Promise wrapper around Tasks for async operationsHTTP.Request- Request configuration structHTTP.Response- Response struct with JSON/text parsing helpersHTTP.Headers- Header manipulation utilitiesHTTP.FormData- Multipart/form-data encoding with file upload supportHTTP.AbortController- Request cancellation mechanismHTTP.FetchOptions- Options processing and validationHTTP.Telemetry- Telemetry event emission for monitoring
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
Functions
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 shouldHTTP.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