agentleFS
igmarinigmarin/elixir-phoenix-skills/skills/req-http-client/SKILL.md

req-http-client

Skill4 starsChanged 4 months agoMITView on GitHub

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.

The file

We split this skill into the rules it gives an agent

2 rules. About 0 are common, 1 is distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Follow this sequence when integrating an external API:

    line 66
  2. Very specific:

    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 …

    line 214

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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
PatternExample call
GETReq.get("https://api.example.com/users", params: %{page: 1})
POST JSONReq.post("https://api.example.com/users", json: %{name: "John"})
POST formReq.post("https://api.example.com/login", form: [username: "john", password: "secret"])
With error handlingWrap 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 exceptionsReq.get/1 and pattern match {:ok, _} / {:error, _}
Rebuild Req.new/1 options on every callBuild a base client once and reuse it
Treat any non-200 status the sameMatch 404, 429, and status >= 500 explicitly
retry: :transient on non-idempotent POSTsRetry only idempotent requests; handle writes deliberately
Leave receive_timeout at the defaultSet an explicit timeout for every external call
Hit the real API in the test suiteStub with Req.Test.stub/2
Load a large response into memoryStream with into: File.stream!(path)
Hardcode tokens in the client moduleRead from Application.get_env/2 / runtime config

Integration

PredecessorThis SkillSuccessor
elixir-essentialsreq-http-clienttesting-essentials
None (standalone)req-http-clientoban-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

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.