Skip to content
Development
Skill

/http-clients-req

Use when calling external HTTP APIs with Req — client modules, Req.new options, retries and Retry-After, timeouts, Finch pools, idempotency keys, Req.Test stubs, SSRF-safe redirects. Not for Oban job structure (use oban-essentials) or serving JSON APIs (use phoenix-json-api).

BOOST
From plugin
elixir-phoenix-guide
16924 skills3 hooks
Install
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill http-clients-req --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/http-clients-req

Context preview

The summary Claude sees to decide when to auto-load this skill.

Use when calling external HTTP APIs with Req — client modules, Req.new options, retries and Retry-After, timeouts, Finch pools, idempotency keys, Req.Test stubs, SSRF-safe redirects. Not for Oban job structure (use oban-essentials) or serving JSON APIs (use phoenix-json-api).

SKILL.md

http-clients-req.SKILL.md
name: http-clients-req
description: "Use when calling external HTTP APIs with Req — client modules, Req.new options, retries and Retry-After, timeouts, Finch pools, idempotency keys, Req.Test stubs, SSRF-safe redirects. Not for Oban job structure (use oban-essentials) or serving JSON APIs (use phoenix-json-api)."

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

1. **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{}`. 2. **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}`, and `Req.get!/2` doesn't raise on them either. Use bang functions only where a crash is fine (scripts, seeds, mix tasks). 3. **Know the default retry.** `retry: :safe_transient` retries 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 obeys `Retry-After` with no upper limit. Cap that delay, and lower `max_retries` on user-facing paths. 4. **`retry: false` inside Oban workers.** Oban already retries the job with backoff; Req retries on top multiply the attempts and hold the queue slot while they sleep. 5. **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. Then `retry: :transient` is safe. 6. **Set every timeout on purpose.** Connect (5 s by default), `receive_timeout` (15 s per read), `request_timeout` (total time, new in 0.7), and `finch: [pool_timeout: ...]`. Never block a LiveView event handler on a slow API: use `start_async`. 7. **Expect bodies of any shape.** `json:` encodes with Jason. Responses are decoded by `content-type` into maps with string keys. A malformed JSON body is `{:error, %Jason.DecodeError{}}`, and an HTML error page from a proxy stays a binary. 8. **Never fetch a user-supplied URL without a host allowlist and `redirect: false`.** On a redirect to another host, Req drops only `authorization`, so custom API-key headers go along. Never log request headers or bodies, and never use `verify: :verify_none`. 9. **Test with `plug: {Req.Test, MyApp.Client}` and `retry: false` in `config/test.exs`.** Use Mox only at a behaviour boundary, when callers shouldn't see HTTP at all. 10. **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: combining `finch:` with `connect_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.exs` runs.
  • **Option names are validated.** `Req.new(bas_url: ...)` raises `ArgumentError` ("Did you mean :base_url?"). Custom options need `Req.Request.register_options/2` (see the [reference](reference.md)).
  • **Merging:** `Req.merge/2` and per-call options merge `headers` and `params` and override everything else. Options passed later win, so a caller's `retry: false` beats 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!()} end` is evaluated on every attempt. So is `{mod, fun, args}`.
  • **Version check:** Phoenix 1.8 generates `{:req, "~> 0.5"}`. A fresh `mix deps.get` resolves that to 0.7.x, but an older `mix.lock` may still pin 0.5. Several options changed; see [Upgrading from 0.5](#upgrading-from-req-05).

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

Read more
Ships withelixir-phoenix-guide

· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.

Get the whole plugin

Other skills on elixir-phoenix-guide.