Current section
Files
Jump to
Current section
Files
lib/httpower.ex
defmodule HTTPower do
@moduledoc """
Production reliability layer for Elixir HTTP clients. Adds reliability patterns
and enterprise features on top of Finch, Req, or Tesla through an adapter system.
HTTPower supports multiple HTTP clients via adapters — Finch (high-performance, default),
Req (batteries-included), and Tesla (bring-your-own-config) — while providing production
reliability features that work consistently across all of them:
- **Adapter pattern**: Choose between Finch, Req, or Tesla HTTP clients
- **Middleware pipeline**: Rate limiting, circuit breaker, and request deduplication
- **Smart retries**: Exponential backoff with jitter and Retry-After header support
- **PCI-compliant logging**: Automatic sanitization of sensitive data with structured metadata
- **Telemetry integration**: Comprehensive observability for all operations
- **Configuration profiles**: Pre-built profiles for payment processing, high-volume APIs, and microservices
- **Clean error handling**: Never raises exceptions, always returns `{:ok, response}` or `{:error, reason}`
- **Test utilities**: Adapter-agnostic test helpers via `HTTPower.Test`
## Basic Usage
# Simple GET request
HTTPower.get("https://api.example.com/users")
# POST with JSON body
HTTPower.post("https://api.example.com/users",
json: %{name: "Alice", email: "alice@example.com"})
# POST with form data
HTTPower.post("https://api.example.com/login",
form: [username: "alice", password: "secret"])
# POST with raw body
HTTPower.post("https://api.example.com/upload",
body: raw_bytes,
headers: %{"Content-Type" => "application/octet-stream"})
# Skip response decoding
HTTPower.get("https://api.example.com/data", raw: true)
# With configuration options
HTTPower.get("https://api.example.com/slow-endpoint",
timeout: 30,
max_retries: 5,
retry_safe: true
)
## Test Mode
HTTPower can block real HTTP requests during testing while allowing mocked requests:
# In test configuration
Application.put_env(:httpower, :test_mode, true)
# This will be blocked
HTTPower.get("https://real-api.com") # {:error, %HTTPower.Error{reason: :network_blocked}}
# But this will work with Req.Test
HTTPower.get("https://api.com", plug: {Req.Test, MyApp})
## Configuration Options
- `timeout` - Request timeout in seconds (default: 60)
- `max_retries` - Maximum retries after initial attempt (default: 3)
- `retry_safe` - Enable retries for connection resets (default: false)
- `ssl_verify` - Enable SSL verification (default: true). **Adapter-specific:** honored
per-request only by the Req adapter. The default Finch adapter configures TLS at the
pool level via `config :httpower, :finch_pools` and ignores this option per-request
(certificates are still verified by default). Tesla configures TLS on its client.
- `proxy` - Proxy configuration (default: `:system`, which means a direct connection —
there is no system-proxy auto-detection). Adapter-specific like `ssl_verify`: per-request
on Req, pool-level on Finch, client-level on Tesla.
- `pool_timeout` - Finch adapter only: max time in ms to wait for a pooled connection
(default: Finch's 5000ms)
- `headers` - Request headers map
- `json` - Data to encode as JSON request body (sets Content-Type and Accept headers)
- `form` - Data to encode as form-urlencoded request body (keyword list or map, flat only)
- `params` - Query parameters to append to the URL (keyword list or map, flat only)
- `raw` - Skip automatic response body decoding when true (default: false)
## Return Values
All HTTP methods return either:
- `{:ok, %HTTPower.Response{}}` - an HTTP response was received (any status code)
- `{:error, %HTTPower.Error{}}` - a transport/network error occurred
**Important:** `{:ok, response}` means the server responded, not that the request
"succeeded" in a business logic sense. This includes 4xx and 5xx responses. After
retries are exhausted for retryable status codes (500, 502, 503, 504), the final
server response is still returned as `{:ok, response}`. Always check
`response.status` to determine the HTTP outcome:
case HTTPower.get("https://api.example.com/users") do
{:ok, %{status: status}} when status in 200..299 ->
# Success
{:ok, %{status: status} = response} ->
# Server responded with non-2xx (including 5xx after retries exhausted)
{:error, %HTTPower.Error{reason: reason}} ->
# Transport error (timeout, connection refused, etc.)
end
HTTPower never raises exceptions for HTTP operations, ensuring your application
stays stable even when external services fail. Configuration errors (such as
passing an unknown profile to `new/1`) raise `ArgumentError` at client
construction time, following standard Elixir conventions.
## Configured Clients
You can create pre-configured client instances for reuse:
# Create a configured client
client = HTTPower.new(
base_url: "https://api.example.com",
headers: %{"Authorization" => "Bearer token"},
timeout: 30,
max_retries: 5
)
# Use the client for multiple requests
HTTPower.get(client, "/users")
HTTPower.post(client, "/users", json: %{name: "John"})
This is especially useful for API clients, different environments, or service-specific configuration.
"""
alias HTTPower.Client
@type client :: %__MODULE__{
base_url: String.t() | nil,
options: keyword()
}
defstruct base_url: nil, options: []
@doc """
Creates a new HTTPower client with pre-configured options.
Raises `ArgumentError` if an unknown profile is specified.
## Options
- `base_url` - Base URL to prepend to all requests
- `profile` - Pre-configured profile (`:payment_processing`, `:high_volume_api`, `:microservices_mesh`)
- All other options are the same as individual request options (see module documentation)
When using a profile, profile settings are merged with explicit options.
Explicit options always take precedence over profile defaults.
## Examples
# Simple client with base URL
client = HTTPower.new(base_url: "https://api.example.com")
# Client with authentication and timeouts
client = HTTPower.new(
base_url: "https://api.example.com",
headers: %{"Authorization" => "Bearer token"},
timeout: 30,
max_retries: 5,
retry_safe: true
)
# Use a profile for optimal settings
client = HTTPower.new(
base_url: "https://payment-gateway.com",
profile: :payment_processing
)
# Profile with overrides
client = HTTPower.new(
base_url: "https://api.example.com",
profile: :high_volume_api,
rate_limit: [requests: 2000] # Override profile's rate limit
)
"""
@spec new(keyword()) :: client()
def new(opts \\ []) do
# Extract profile and base_url
{profile, opts} = Keyword.pop(opts, :profile)
{base_url, opts} = Keyword.pop(opts, :base_url)
# Merge profile settings with user options (user options win)
options =
case profile do
nil ->
opts
profile_name ->
case HTTPower.Profiles.get(profile_name) do
{:ok, profile_config} ->
deep_merge_options(profile_config, opts)
{:error, :unknown_profile} ->
raise ArgumentError, """
Unknown profile: #{inspect(profile_name)}
Available profiles: #{inspect(HTTPower.Profiles.list())}
"""
end
end
%__MODULE__{base_url: base_url, options: options}
end
@doc """
Makes an HTTP GET request.
Accepts either a URL string or a configured client as the first argument.
## Options
See module documentation for available options.
## Examples
# With URL string
HTTPower.get("https://api.example.com/users")
HTTPower.get("https://api.example.com/users", headers: %{"Authorization" => "Bearer token"})
# With configured client
client = HTTPower.new(base_url: "https://api.example.com")
HTTPower.get(client, "/users")
"""
# Function header with default value
def get(url_or_client, opts_or_path \\ [])
# URL + options pattern
@spec get(String.t(), keyword()) :: {:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def get(url, opts) when is_binary(url) do
Client.get(url, opts)
end
# Client + path pattern (calls 3-arity with empty options)
@spec get(client(), String.t()) :: {:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def get(%__MODULE__{} = client, path) when is_binary(path) do
get(client, path, [])
end
# Client + path + options pattern (3-arity)
@spec get(client(), String.t(), keyword()) ::
{:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def get(%__MODULE__{} = client, path, opts) when is_binary(path) and is_list(opts) do
{url, merged_opts} = prepare_client_request(client, path, opts)
Client.get(url, merged_opts)
end
@doc """
Makes an HTTP POST request.
Accepts either a URL string or a configured client as the first argument.
## Options
See module documentation for available options. Additionally supports:
- `json` - Data to encode as JSON request body
- `form` - Data to encode as form-urlencoded request body
- `body` - Raw request body string
## Examples
# With URL string
HTTPower.post("https://api.example.com/users", json: %{name: "John"})
HTTPower.post("https://api.example.com/login",
form: [username: "alice", password: "secret"]
)
# With configured client
client = HTTPower.new(base_url: "https://api.example.com")
HTTPower.post(client, "/users", json: %{name: "John"})
"""
# Function header with default value
def post(url_or_client, opts_or_path \\ [])
# URL + options pattern
@spec post(String.t(), keyword()) :: {:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def post(url, opts) when is_binary(url) do
Client.post(url, opts)
end
# Client + path pattern (calls 3-arity with empty options)
@spec post(client(), String.t()) :: {:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def post(%__MODULE__{} = client, path) when is_binary(path) do
post(client, path, [])
end
# Client + path + options pattern (3-arity)
@spec post(client(), String.t(), keyword()) ::
{:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def post(%__MODULE__{} = client, path, opts) when is_binary(path) and is_list(opts) do
{url, merged_opts} = prepare_client_request(client, path, opts)
Client.post(url, merged_opts)
end
@doc """
Makes an HTTP PUT request.
Accepts either a URL string or a configured client as the first argument.
## Options
See module documentation for available options. Additionally supports:
- `json` - Data to encode as JSON request body
- `form` - Data to encode as form-urlencoded request body
- `body` - Raw request body string
## Examples
# With URL string
HTTPower.put("https://api.example.com/users/1", json: %{name: "John"})
# With configured client
client = HTTPower.new(base_url: "https://api.example.com")
HTTPower.put(client, "/users/1", json: %{name: "John"})
"""
# Function header with default value
def put(url_or_client, opts_or_path \\ [])
# URL + options pattern
@spec put(String.t(), keyword()) :: {:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def put(url, opts) when is_binary(url) do
Client.put(url, opts)
end
# Client + path pattern (calls 3-arity with empty options)
@spec put(client(), String.t()) :: {:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def put(%__MODULE__{} = client, path) when is_binary(path) do
put(client, path, [])
end
# Client + path + options pattern (3-arity)
@spec put(client(), String.t(), keyword()) ::
{:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def put(%__MODULE__{} = client, path, opts) when is_binary(path) and is_list(opts) do
{url, merged_opts} = prepare_client_request(client, path, opts)
Client.put(url, merged_opts)
end
@doc """
Makes an HTTP DELETE request.
Accepts either a URL string or a configured client as the first argument.
## Options
See module documentation for available options.
## Examples
# With URL string
HTTPower.delete("https://api.example.com/users/1")
# With configured client
client = HTTPower.new(base_url: "https://api.example.com")
HTTPower.delete(client, "/users/1")
"""
# Function header with default value
def delete(url_or_client, opts_or_path \\ [])
# URL + options pattern
@spec delete(String.t(), keyword()) ::
{:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def delete(url, opts) when is_binary(url) do
Client.delete(url, opts)
end
# Client + path pattern (calls 3-arity with empty options)
@spec delete(client(), String.t()) ::
{:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def delete(%__MODULE__{} = client, path) when is_binary(path) do
delete(client, path, [])
end
# Client + path + options pattern (3-arity)
@spec delete(client(), String.t(), keyword()) ::
{:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def delete(%__MODULE__{} = client, path, opts) when is_binary(path) and is_list(opts) do
{url, merged_opts} = prepare_client_request(client, path, opts)
Client.delete(url, merged_opts)
end
@doc """
Makes an HTTP PATCH request.
Accepts either a URL string or a configured client as the first argument.
## Examples
HTTPower.patch("https://api.example.com/users/1", json: %{name: "Jane"})
client = HTTPower.new(base_url: "https://api.example.com")
HTTPower.patch(client, "/users/1", json: %{name: "Jane"})
"""
def patch(url_or_client, opts_or_path \\ [])
@spec patch(String.t(), keyword()) ::
{:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def patch(url, opts) when is_binary(url) do
Client.patch(url, opts)
end
@spec patch(client(), String.t()) :: {:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def patch(%__MODULE__{} = client, path) when is_binary(path) do
patch(client, path, [])
end
@spec patch(client(), String.t(), keyword()) ::
{:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def patch(%__MODULE__{} = client, path, opts) when is_binary(path) and is_list(opts) do
{url, merged_opts} = prepare_client_request(client, path, opts)
Client.patch(url, merged_opts)
end
@doc """
Makes an HTTP HEAD request.
Accepts either a URL string or a configured client as the first argument.
## Examples
HTTPower.head("https://api.example.com/users")
client = HTTPower.new(base_url: "https://api.example.com")
HTTPower.head(client, "/users")
"""
def head(url_or_client, opts_or_path \\ [])
@spec head(String.t(), keyword()) :: {:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def head(url, opts) when is_binary(url) do
Client.head(url, opts)
end
@spec head(client(), String.t()) :: {:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def head(%__MODULE__{} = client, path) when is_binary(path) do
head(client, path, [])
end
@spec head(client(), String.t(), keyword()) ::
{:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def head(%__MODULE__{} = client, path, opts) when is_binary(path) and is_list(opts) do
{url, merged_opts} = prepare_client_request(client, path, opts)
Client.head(url, merged_opts)
end
@doc """
Makes an HTTP OPTIONS request.
Accepts either a URL string or a configured client as the first argument.
## Examples
HTTPower.options("https://api.example.com/users")
client = HTTPower.new(base_url: "https://api.example.com")
HTTPower.options(client, "/users")
"""
def options(url_or_client, opts_or_path \\ [])
@spec options(String.t(), keyword()) ::
{:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def options(url, opts) when is_binary(url) do
Client.options(url, opts)
end
@spec options(client(), String.t()) ::
{:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def options(%__MODULE__{} = client, path) when is_binary(path) do
options(client, path, [])
end
@spec options(client(), String.t(), keyword()) ::
{:ok, HTTPower.Response.t()} | {:error, HTTPower.Error.t()}
def options(%__MODULE__{} = client, path, opts) when is_binary(path) and is_list(opts) do
{url, merged_opts} = prepare_client_request(client, path, opts)
Client.options(url, merged_opts)
end
# Private helper function to prepare client requests
defp prepare_client_request(
%__MODULE__{base_url: base_url, options: client_opts},
path,
request_opts
) do
url = build_url(base_url, path)
merged_opts = merge_client_options(client_opts, request_opts)
{url, merged_opts}
end
defp build_url(nil, path), do: path
defp build_url(base_url, ""), do: base_url
defp build_url(base_url, "/" <> _ = path), do: String.trim_trailing(base_url, "/") <> path
defp build_url(base_url, path), do: String.trim_trailing(base_url, "/") <> "/" <> path
defp merge_client_options(client_opts, request_opts) do
# Merge headers specially - combine rather than replace
client_headers = Keyword.get(client_opts, :headers, %{})
request_headers = Keyword.get(request_opts, :headers, %{})
merged_headers = Map.merge(client_headers, request_headers)
# Merge all options, with request options taking precedence
client_opts
|> Keyword.merge(request_opts)
|> Keyword.put(:headers, merged_headers)
end
defp deep_merge_options(profile_config, user_opts) do
Keyword.merge(profile_config, user_opts, fn _key, profile_val, user_val ->
if Keyword.keyword?(profile_val) and Keyword.keyword?(user_val) do
Keyword.merge(profile_val, user_val)
else
user_val
end
end)
end
@doc """
Checks if HTTPower is currently in test mode.
In test mode, real HTTP requests are blocked unless they include a `:plug` option
for mocking with Req.Test.
## Examples
Application.put_env(:httpower, :test_mode, true)
HTTPower.test_mode?() # true
Application.put_env(:httpower, :test_mode, false)
HTTPower.test_mode?() # false
"""
@spec test_mode?() :: boolean()
def test_mode? do
Application.get_env(:httpower, :test_mode, false)
end
end