SuperAPI AI Directory

req-http-client

Coding & development

View repository

Updated 10-15 22:16

About this skill

Req HTTP Client

Examples use base_url / url variables or https://api.example.com as placeholders only — never commit real host secrets.

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/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.

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