# Req HTTP Client

> Use when making HTTP requests from Elixir applications. Invoke before integrating external APIs. Covers Req setup, request patterns, error handling, retries, timeouts, and testing with Req.Test. Req is the modern HTTP client for Elixir, replacing HTTPoison and Tesla. Trigger words: Req, HTTP client, HTTP request, API integration, external API, HTTPoison replacement.

- Skill: `igmarin/req-http-client` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add igmarin/req-http-client`
- Raw SKILL.md: https://api.skillmd.com/api/skills/igmarin/req-http-client/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- License: MIT
- Author: igmarin (https://skillmd.com/u/igmarin)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/igmarin/req-http-client

---

# 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`](../../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](#rules--follow-these-with-no-exceptions) · [End-to-End Workflow](#end-to-end-workflow) · [Quick-Reference: Request Types](#quick-reference-request-types) · [Retries](#retries) · [Streaming Responses](#streaming-responses) · [Common Pitfalls](#common-pitfalls) · [Integration](#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`](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

```elixir
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

```elixir
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**
```elixir
# 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**
```elixir
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**
```elixir
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**
```elixir
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/2` and `Req.post/2` in application code. The bang (`!`) variants such as `Req.get!/2` are 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 on `status`.

```elixir
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

```elixir
# 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:

```elixir
# 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 with `Req.Test` in the suite
- `oban-essentials` — retry and schedule outbound API calls in background jobs
- `cachex-caching` — cache responses from slow or rate-limited APIs

