Req HTTP Client
Examples use base_url / url variables or https://api.example.com as placeholders only — never commit real host secrets.
Canonical FP bar: docs/fcis-engineering-rules.md — Functional Core, Imperative Shell: pure domain modules; side effects at edges. HTTP/email/i18n adapters are edges; keep request building and response mapping pure where possible.
Sections: RULES · End-to-End Workflow · Quick-Reference: Request Types · Retries · Streaming Responses · Common Pitfalls · Integration
RULES — Follow these with no exceptions
1. Always build a configured base client with Req.new/1 — set base_url, receive_timeout, and default headers once, then reuse it for every call instead of re-passing options
2. Use the non-bang Req.get/1 / Req.post/1 in application code — pattern match {:ok, %{status: _, body: _}} / {:error, _}; reserve the ! variants for scripts and tests
3. Match status codes explicitly — handle 404, 429, and status >= 500 distinctly; never collapse every non-200 into one branch
4. Enable retry: :transient only for idempotent requests — Req retries 5xx and network errors with backoff; never blindly retry non-idempotent writes
5. Set an explicit receive_timeout — never rely on infinite defaults for calls to external services
6. Stub every external call in tests with Req.Test — the suite must never hit a real API
7. Stream large responses with into: — write to File.stream!/1 or a callback instead of loading the full payload into memory
See assets/req_client_snippets.ex for a copy-paste base client and wrapper module.
FCIS at this boundary
Build requests and map responses in pure functions; perform I/O in a thin client module.
❌ Bad: business branching mixed with HTTP side effects inline
def import_user(id) do
{:ok, %{status: 200, body: body}} = Req.get("https://api.example.com/users/#{id}")
rank = if body["score"] > 10, do: :gold, else: :silver
Repo.insert!(%User{external_id: id, rank: rank})
end
✅ Good: pure map + thin client + context edge
def rank_from_payload(%{"score" => score}) when score > 10, do: :gold
def rank_from_payload(_), do: :silver
def import_user(id) do
with {:ok, body} <- API.Client.fetch_user(id),
rank <- rank_from_payload(body),
{:ok, user} <- Accounts.upsert_external(id, rank) do
{:ok, user}
end
end
End-to-End Workflow
Follow this sequence when integrating an external API:
Step 1 — Add dependency
# mix.exs
defp deps do
[
{:req, "~> 0.5"}
]
end
Checkpoint: run mix deps.get and confirm Req compiles without errors.
Step 2 — Create a configured client module
defmodule MyApp.ApiClient do
def base_request do
Req.new(
base_url: Application.get_env(:my_app, :api_base_url),
headers: [{"authorization", "Bearer #{api_token()}"}],
receive_timeout: 30_000,
retry: :transient
)
end
def fetch_user(id) do
case Req.get(base_request(), url: "/users/#{id}") do
{:ok, %{status: 200, body: body}} -> {:ok, body}
{:ok, %{status: 404}} -> {:error, :not_found}
{:ok, %{status: 429}} -> {:error, :rate_limited} # extend with additional codes as needed
{:ok, %{status: status}} when status >= 500 -> {:error, :server_error}
{:ok, %{status: status}} -> {:error, {:unexpected_status, status}}
{:error, %Mint.TransportError{reason: :timeout}} -> {:error, :timeout}
{:error, exception} -> {:error, Exception.message(exception)}
end
end
defp api_token, do: Application.get_env(:my_app, :api_token)
end
Checkpoint: verify the module compiles with mix compile.
Step 3 — Test with Req.Test before touching a real API
defmodule MyApp.ApiClientTest do
use ExUnit.Case, async: true
setup do
Req.Test.adapter(MyApp.ApiClient)
:ok
end
test "fetches user successfully" do
Req.Test.stub(MyApp.ApiClient, fn conn ->
Req.Test.json(conn, %{"id" => 1, "name" => "John"})
end)
assert {:ok, %{"name" => "John"}} = MyApp.ApiClient.fetch_user(1)
end
test "handles not found" do
Req.Test.stub(MyApp.ApiClient, fn conn ->
conn |> Plug.Conn.put_status(404) |> Req.Test.json(%{"error" => "not found"})
end)
assert {:error, :not_found} = MyApp.ApiClient.fetch_user(999)
end
end
Checkpoint: run mix test — all stubs must pass before using the real API.
Step 4 — Verify in IEx against the real endpoint
iex> MyApp.ApiClient.fetch_user(1)
{:ok, %{"id" => 1, "name" => "John", ...}}
Checkpoint: confirm a {:ok, body} tuple is returned; check logs for retry warnings if the request is slow.
Quick-Reference: Request Types
Use the non-bang
Req.get/2andReq.post/2in application code. The bang (!) variants such asReq.get!/2are only for REPL/scripts where a crash is acceptable. Req returns{:ok, response}for HTTP-level responses (including 4xx/5xx) and{:error, reason}for transport failures, so always match onstatus.
case Req.get("https://api.example.com/users", params: %{page: 1}) do
{:ok, %{status: status, body: body}} when status in 200..299 ->
{:ok, body}
{:ok, %{status: status}} ->
{:error, {:http, status}}
{:error, reason} ->
{:error, reason}
end
| Pattern | Example call |
|---|---|
| GET | Req.get("https://api.example.com/users", params: %{page: 1}) |
| POST JSON | Req.post("https://api.example.com/users", json: %{name: "John"}) |
| POST form | Req.post("https://api.example.com/login", form: [username: "john", password: "secret"]) |
| With error handling | Wrap in case and pattern-match on status (see example above). |
Retries
# Automatic retries for transient failures
url = "https://api.example.com/data" # example.com used as RFC 2606 placeholder host
case Req.get(url,
retry: :transient, # Retry on 5xx and network errors
retry_delay: &(&1 * 1000), # Exponential backoff: 1s, 2s, 4s, ...
max_retries: 3, # Max 3 retries
retry_log_level: :warning
) do
{:ok, %{status: status} = resp} when status in 200..299 ->
resp
{:ok, %{status: status}} ->
{:error, {:http, status}}
{:error, reason} ->
{:error, reason}
end
# Custom retry logic (e.g. also retry on 429)
case Req.get("https://api.example.com/data",
retry: fn response ->
case response do
%{status: 429} -> true
%{status: s} when s >= 500 -> true
_ -> false
end
end,
max_retries: 3
) do
{:ok, %{status: status} = resp} when status in 200..299 ->
resp
{:ok, %{status: status}} ->
{:error, {:http, status}}
{:error, reason} ->
{:error, reason}
end
Streaming Responses
Prefer into: over loading the full response into memory — this is Req's built-in
streaming support, and it's the right tool whenever a response body could be large or
unbounded:
# Stream large responses to a file. File-write errors (e.g., disk full) propagate as {:error, reason}.
case Req.get("https://api.example.com/large-file",
into: File.stream!("download.txt")
) do
{:ok, %{status: status}} when status in 200..299 ->
:ok
{:ok, %{status: status}} ->
{:error, {:http, status}}
{:error, reason} ->
{:error, reason}
end
# Stream with a callback
case Req.get("https://api.example.com/stream",
into: fn {:data, data}, {req, resp} ->
IO.puts("Received #{byte_size(data)} bytes")
{:cont, {req, resp}}
end
) do
{:ok, %{status: status}} when status in 200..299 ->
:ok
{:ok, %{status: status}} ->
{:error, {:http, status}}
{:error, reason} ->
{:error, reason}
end
Common Pitfalls
| ❌ Don't | ✅ Do |
|---|---|
Req.get! in app code and rescue exceptions |
Req.get/1 and pattern match {:ok, _} / {:error, _} |
Rebuild Req.new/1 options on every call |
Build a base client once and reuse it |
| Treat any non-200 status the same | Match 404, 429, and status >= 500 explicitly |
retry: :transient on non-idempotent POSTs |
Retry only idempotent requests; handle writes deliberately |
Leave receive_timeout at the default |
Set an explicit timeout for every external call |
| Hit the real API in the test suite | Stub with Req.Test.stub/2 |
| Load a large response into memory | Stream with into: File.stream!(path) |
| Hardcode tokens in the client module | Read from Application.get_env/2 / runtime config |
Integration
| Predecessor | This Skill | Successor |
|---|---|---|
| elixir-essentials | req-http-client | testing-essentials |
| None (standalone) | req-http-client | oban-essentials |
Companion skills:
testing-essentials— stub HTTP calls withReq.Testin the suiteoban-essentials— retry and schedule outbound API calls in background jobscachex-caching— cache responses from slow or rate-limited APIs