HTTP Clients with Req
Verified against Elixir 1.20, Phoenix 1.8, Req 0.7, Finch 0.24 — October 2026
RULES — Follow these with no exceptions
- One client module per external API. Build the
%Req.Request{}in a private function from runtime config (Application.fetch_env!/2), never in a module attribute. Merge test-only options (plug:) from config, and return domain values ({:ok, celsius}), not%Req.Response{}. - Match every outcome of
Req.request/2: each status you expect, a catch-all{:ok, %Req.Response{status: status}}, and{:error, exception}. 4xx/5xx responses arrive as{:ok, response}, andReq.get!/2doesn't raise on them either. Use bang functions only where a crash is fine (scripts, seeds, mix tasks). - Know the default retry.
retry: :safe_transientretries GET/HEAD up to 3 times on 408/429/500/502/503/504 and on transport timeouts or refused/closed connections. It sleeps in the calling process and obeysRetry-Afterwith no upper limit. Cap that delay, and lowermax_retrieson user-facing paths. retry: falseinside Oban workers. Oban already retries the job with backoff; Req retries on top multiply the attempts and hold the queue slot while they sleep.- Retry POST/PUT/PATCH only with an idempotency key built from the business record (
"order-#{id}-charge"), so it stays the same across Req retries and Oban attempts. Thenretry: :transientis safe. - Set every timeout on purpose. Connect (5 s by default),
receive_timeout(15 s per read),request_timeout(total time, new in 0.7), andfinch: [pool_timeout: ...]. Never block a LiveView event handler on a slow API: usestart_async. - Expect bodies of any shape.
json:encodes with Jason. Responses are decoded bycontent-typeinto maps with string keys. A malformed JSON body is{:error, %Jason.DecodeError{}}, and an HTML error page from a proxy stays a binary. - Never fetch a user-supplied URL without a host allowlist and
redirect: false. On a redirect to another host, Req drops onlyauthorization, so custom API-key headers go along. Never log request headers or bodies, and never useverify: :verify_none. - Test with
plug: {Req.Test, MyApp.Client}andretry: falseinconfig/test.exs. Use Mox only at a behaviour boundary, when callers shouldn't see HTTP at all. - Use a named Finch pool only for pool needs (size, HTTP/2, per-host config). Pass it as
finch: [name: MyApp.Finch](the 0.7 form) and put connect options in the pool config: combiningfinch:withconnect_options:raises.
The client module
defmodule MyApp.Weather do
@moduledoc "Client for the Example Weather API."
@doc "Current temperature in °C. `opts` are extra Req options, e.g. `retry: false` in Oban workers."
def current(city, opts \\ []) when is_binary(city) do
case Req.request(new(), [url: "/v1/current", params: [city: city]] ++ opts) do
{:ok, %Req.Response{status: 200, body: %{"celsius" => celsius}}} -> {:ok, celsius}
{:ok, %Req.Response{status: 404}} -> {:error, :not_found}
{:ok, %Req.Response{status: 429}} -> {:error, :rate_limited}
{:ok, %Req.Response{status: status}} -> {:error, {:unexpected_response, status}}
{:error, exception} -> {:error, exception}
end
end
defp new do
config = Application.fetch_env!(:my_app, __MODULE__)
Req.new(
base_url: Keyword.fetch!(config, :base_url),
auth: {:bearer, Keyword.fetch!(config, :api_key)},
receive_timeout: 5_000,
max_retries: 2
)
|> Req.merge(Keyword.get(config, :req_options, []))
end
end
# config/config.exs
config :my_app, MyApp.Weather, base_url: "https://api.weather.example"
# config/runtime.exs: secrets are read at boot, not at compile time
if config_env() == :prod do
config :my_app, MyApp.Weather, api_key: System.fetch_env!("WEATHER_API_KEY")
end
# config/test.exs
config :my_app, MyApp.Weather,
api_key: "test-key",
req_options: [plug: {Req.Test, MyApp.Weather}, retry: false]
- Build the struct per call. It's cheap and does no I/O; connections live in Finch pools and are reused. A module attribute would be evaluated at compile time, before
runtime.exsruns. - Option names are validated.
Req.new(bas_url: ...)raisesArgumentError("Did you mean :base_url?"). Custom options needReq.Request.register_options/2(see the reference). - Merging:
Req.merge/2and per-call options mergeheadersandparamsand override everything else. Options passed later win, so a caller'sretry: falsebeats the client default. - The
{:error, exception}side is usually%Req.TransportError{reason: :timeout | :econnrefused | :closed | ...}, but can also be%Req.HTTPError{},%Req.TooManyRedirectsError{}or%Jason.DecodeError{}. - Rotating tokens:
auth: fn -> {:bearer, MyApp.Tokens.fetch!()} endis evaluated on every attempt. So is{mod, fun, args}. - Version check: Phoenix 1.8 generates
{:req, "~> 0.5"}. A freshmix deps.getresolves that to 0.7.x, but an oldermix.lockmay still pin 0.5. Several options changed; see Upgrading from 0.5.
Retries
| | retry: :safe_transient (default) |
|---|---|
| Methods | GET, HEAD (:transient retries every method) |
| Responses | 408, 429, 500, 502, 503, 504 |
| Exceptions | Req.TransportError with :timeout, :econnrefused, :closed; HTTP/2 :unprocessed, :pool_not_available |
| Attempts | max_retries: 3, so up to 4 requests in total |
| Delay | exponential with jitter (~1 s, 2 s, 4 s); on 429/503 the Retry-After value (seconds or HTTP date), with no cap |
- The delay is a
Process.sleep/1in the caller. ARetry-After: 3600freezes a controller request or a LiveView for an hour. - Setting
retry_delaymakes Req ignoreRetry-After(changed in 0.7). To keep honouring the header with a ceiling, pass a 2-arityretryfunction that returns{:delay, ms}. - Retries log at
:warningby default. Useretry_log_level: falseto silence them, or:debug.
# In the client: Req.new(..., retry: &retry?/2)
# Same policy as :safe_transient, but caps Retry-After at 5 s
defp retry?(%Req.Request{method: method}, response_or_exception) when method in [:get, :head] do
case response_or_exception do
%Req.Response{status: status} = response when status in [429, 503] ->
{:delay, min(Req.Response.get_retry_after(response) || 1_000, 5_000)}
%Req.Response{status: status} ->
status in [408, 500, 502, 504]
%Req.TransportError{reason: reason} ->
reason in [:timeout, :econnrefused, :closed]
_other ->
false
end
end
defp retry?(_request, _response_or_exception), do: false
Req.Response.get_retry_after/1 returns milliseconds, even though its docs say seconds. A function that returns {:delay, ms} raises if retry_delay is also set; returning true uses the default backoff.
Inside Oban workers
@impl Oban.Worker
def perform(%Oban.Job{args: %{"city" => city}}) do
case MyApp.Weather.current(city, retry: false) do
{:ok, celsius} -> MyApp.Forecasts.record_reading(city, celsius)
{:error, :not_found} -> {:cancel, :unknown_city}
{:error, reason} -> {:error, reason}
end
end
Oban backs off between attempts and keeps the job's history. Snoozing on 429 and timeout/1 are covered in the oban-essentials skill.
POSTs and idempotency keys
def create_charge(%Order{id: order_id, total_cents: cents}, opts \\ []) do
request_opts = [
url: "/v1/charges",
json: %{amount: cents, currency: "eur", reference: order_id},
# Same key on every attempt, including Oban retries
headers: [{"idempotency-key", "order-#{order_id}-charge"}]
]
case Req.post(new(), Keyword.merge(request_opts, opts)) do
{:ok, %Req.Response{status: status, body: %{"id" => charge_id}}} when status in [200, 201] ->
{:ok, charge_id}
{:ok, %Req.Response{status: 422, body: %{"error" => message}}} ->
{:error, {:rejected, message}}
{:ok, %Req.Response{status: status}} ->
{:error, {:unexpected_response, status}}
{:error, exception} ->
{:error, exception}
end
end
# new/0 for this API sets `retry: :transient`, safe only because every request carries a key
- A timeout doesn't mean the server didn't act. Without a key, retrying a POST can charge twice.
- Never generate the key inside a Req step. Req re-runs every request step on each retry, so a fresh UUID there changes between attempts. If the record has no natural id, generate the key once and store it on the row before the first attempt.
- The header name and semantics come from the provider (Stripe-style
Idempotency-Keyis common). Check how long they keep keys.
Timeouts
| Option | Default | Bounds |
|---|---|---|
| connect_options: [timeout: ms] | 5_000 (Finch's default; Req's docs say 30 s) | TCP + TLS connect |
| receive_timeout: ms | 15_000 | each socket read; a slow trickle can run far longer in total |
| request_timeout: ms (0.7) | :infinity | the whole response; HTTP/1 only, best effort |
| finch: [pool_timeout: ms] | 5_000 | waiting for a free pool connection (top-level pool_timeout: is deprecated in 0.7) |
- The worst case is
(max_retries + 1)attempts × these timeouts, plus the backoff sleeps. With the defaults, a singleReq.getcan take over a minute. - On user-facing paths, set tight values and few retries. In LiveView, run the call in
start_async/3orassign_async/3(see thephoenix-liveview-essentialsskill). - Each distinct
connect_optionsvalue starts its own Finch pool, and Req never stops it. Keep the value constant per client, never computed per request.
JSON bodies
- Encoding:
json: datacallsJason.encode_to_iodata!/1and setscontent-typeandaccepttoapplication/json. Req 0.7 depends on Jason, not Elixir'sJSONmodule:@derive JSON.Encoderdoesn't help, and a struct needsJason.Encoderor raisesProtocol.UndefinedError. Build plain maps. - Decoding: the default
decoders: [:json, :json_api]picks the decoder from the responsecontent-typeand usesJason.decode/1, which gives string keys. Any other type (text/html,text/plain) stays a binary. Match on status first, and never assume an error body is a map. - Bad JSON fails the whole call: a JSON
content-typewith an invalid body returns{:error, %Jason.DecodeError{}}, even for a 200. decode_body: false(ordecoders: false, orraw: true) keeps the body as a binary. Use it when verifying a signature over the raw bytes or decoding yourself.decoders: [json: &Jason.decode(&1, keys: :atoms)]replaces the deprecateddecode_json:option.- Since 0.6, zip/tar/gz/csv bodies are no longer decoded automatically (decompression bombs). Opt in per format with
decoders: [:json, :zip], and only for trusted hosts. Compression is opt-in too:compressed: true.
Security
- SSRF: a URL from a user, a webhook config or job args can point at
169.254.169.254or internal services. Check scheme and host against an allowlist, then request withredirect: false; a redirect is a new, unchecked URL. The full pattern, including DNS-rebinding notes, is in thesecurity-essentialsskill. - Redirects and credentials: when a redirect changes host, scheme or port, Req removes the
authorizationheader and the:authoption (unlessredirect_trusted: true). Custom headers such asx-api-keyare forwarded. For APIs authenticated that way, useredirect: falseor check thelocationyourself. - Logging:
inspect(%Req.Request{})masksauthorizationand:auth, but not other headers, and nothing masks bodies. Log the API name, the status andException.message/1, neverresp.bodyorreq.headers:
require Logger
Logger.warning("weather API call failed: #{Exception.message(exception)}",
api: :weather,
reason: inspect(exception.__struct__)
)
- TLS: never use
connect_options: [transport_opts: [verify: :verify_none]]outside local experiments. For a private CA, usetransport_opts: [cacertfile: path]. - Logger metadata conventions and filtering are in the
telemetry-essentialsskill.
Finch pools
By default Req uses its own Finch instance: an HTTP/1 pool of up to 50 connections per {scheme, host, port}, never terminated. Start a named pool when you need:
- more than 50 concurrent connections to one host, or a smaller cap to protect a fragile partner;
- HTTP/2 multiplexing (
protocols: [:http2]); - per-host settings (connect timeout, proxy, certificates), idle-pool cleanup (
pool_max_idle_time), or pool metrics (start_pool_metrics?: true).
# lib/my_app/application.ex, in children
{Finch,
name: MyApp.Finch,
pools: %{
:default => [size: 50, conn_opts: [transport_opts: [timeout: 5_000]]],
"https://api.payments.example" => [size: 10, count: 2]
}}
# In the client
Req.new(base_url: base_url, finch: [name: MyApp.Finch, pool_timeout: 2_000])
finch: [name: ...]withconnect_options:raises "cannot set both :finch and :connect_options". Put connect settings in the pool'sconn_opts.finch: [name: MyApp.Finch, size: 5]raises too: pool options belong where the pool is started.- The 0.5 form
finch: MyApp.Finchstill works but prints a deprecation warning. - With
plug:in test config, Req uses theReq.Plugadapter and ignores Finch options, so tests don't need the pool.
Testing
The testing-essentials skill covers Req.Test.stub/2, expect/3, json/2, transport_error/2 and verify_on_exit!. What matters most for a client:
defmodule MyApp.PaymentsTest do
use ExUnit.Case, async: true
setup {Req.Test, :verify_on_exit!}
test "posts the charge and keeps the idempotency key across retries" do
test_pid = self()
Req.Test.expect(MyApp.Payments, fn conn ->
send(test_pid, {:key, Plug.Conn.get_req_header(conn, "idempotency-key")})
Plug.Conn.send_resp(conn, 503, "")
end)
Req.Test.expect(MyApp.Payments, fn conn ->
send(test_pid, {:key, Plug.Conn.get_req_header(conn, "idempotency-key")})
assert conn.method == "POST"
assert conn.body_params == %{"amount" => 1999, "currency" => "eur", "reference" => 42}
conn |> Plug.Conn.put_status(201) |> Req.Test.json(%{"id" => "ch_1"})
end)
# Config has retry: false; re-enable it for this call, without the sleeps
assert {:ok, "ch_1"} =
MyApp.Payments.create_charge(%MyApp.Orders.Order{id: 42, total_cents: 1999},
retry: :transient,
retry_delay: 0,
retry_log_level: false
)
assert_receive {:key, ["order-42-charge"]}
assert_receive {:key, ["order-42-charge"]}
end
end
- Assert on the outgoing request:
conn.method,conn.request_path,conn.query_params,Plug.Conn.get_req_header/2, andconn.body_params. TheReq.Plugadapter runsPlug.Parsersfor JSON, form and multipart bodies. UseReq.Test.raw_body(conn)for the exact bytes. - Simulate real failures:
- an error status with a JSON body:
conn |> Plug.Conn.put_status(422) |> Req.Test.json(...); - rate limiting:
Plug.Conn.put_resp_header(conn, "retry-after", "30")beforesend_resp(conn, 429, ""); - a proxy error page:
Req.Test.html(conn, "<h1>Bad gateway</h1>"); - broken JSON:
put_resp_content_type("application/json")with an invalid body; - network errors:
Req.Test.transport_error(conn, :timeout).
- an error status with a JSON body:
- Other processes: since 0.7, processes started by the test (
Task,start_supervised!,GenServer.start) find the test's stubs automatically. Processes started by the application needReq.Test.allow(MyApp.Weather, self(), pid). If you can't get the pid, usesetup {Req.Test, :set_req_test_to_shared}withasync: false. - Mox is for callers that shouldn't know about HTTP, such as a LiveView that shows the temperature. Put a behaviour in front of the client and test the client itself with
Req.Test. Wiring is in the reference.
Observability
- Finch telemetry:
[:finch, :request, :start | :stop | :exception],[:finch, :queue, ...](pool checkout),[:finch, :connect, ...],[:finch, :send, ...],[:finch, :recv, ...],[:finch, :reused_connection]. Metadata has:name(the Finch instance) and:request(a%Finch.Request{}with host, path and method). These events fire once per attempt. Addfinch_private: %{api: :weather}to the client and it shows up asmeta.request.private.api. - One event per logical call (after retries, tagged with your API name): use a small Req plugin that adds a request step and a response/error step. It's in the reference.
- Tracing:
Req.new(...) |> OpentelemetryReq.attach()(opentelemetry_req1.0 attaches cleanly on Req 0.7). Exporter setup is in thetelemetry-essentialsskill. - Use metric tags with few values (
api,status), never the URL with ids in it.
Resilience
- Circuit breaking: when an upstream is down, fail fast instead of tying up every caller for the full timeout. A Req plugin built on
:fusechecks the fuse in a request step (Req.Request.halt/2when blown) and melts it on 5xx and transport errors. Code is in the reference. - Rate limits: treat 429 as a result (
{:error, :rate_limited}). In Oban, snooze for theRetry-Aftervalue. To limit your own call rate, the simplest control is the concurrency of a dedicated Oban queue. Global and distributed rate limits are Oban Pro, or a token bucket you write and own. - Caching: cache the decoded domain value (
{:ok, celsius}), not the%Req.Response{}, in ETS or Cachex with a TTL and a key built from the request inputs. Req's owncache: truestep is deprecated since 0.7.1.
Upgrading from Req 0.5
| 0.5 | 0.7 |
|---|---|
| finch: MyFinch | finch: [name: MyFinch] |
| pool_timeout: ms | finch: [pool_timeout: ms] |
| decode_json: [keys: :atoms] | decoders: [json: &Jason.decode(&1, keys: :atoms)] |
| retry_delay: and Retry-After both apply | setting retry_delay ignores Retry-After |
| zip/tar/gz/csv decoded automatically | only JSON; opt in with decoders: (0.6) |
| compressed responses requested by default | compressed: true to opt in (0.6.1) |
| run_finch / put_plug steps | Req.Finch / Req.Plug adapter modules |
| cache: true | deprecated (0.7.1) |
| retry delays without jitter | jitter by default |