Current section
Files
Jump to
Current section
Files
lib/httpower/adapter/req.ex
if Code.ensure_loaded?(Req) do
defmodule HTTPower.Adapter.Req do
@moduledoc """
Req adapter for HTTPower.
This adapter uses the Req HTTP client library to make HTTP requests. Req is a
"batteries-included" HTTP client with features like automatic body encoding/decoding,
compression, and more.
## Features
- JSON encoding/decoding handled by `HTTPower.Codec` (Req's own request retry and
response decoding are disabled to avoid conflicts/double-decoding)
- Response body decompression (gzip, brotli, zstd)
- SSL/TLS support with configurable verification
- Proxy support (per-request, forwarded to Mint)
- Integration with `Req.Test` for testing
## Configuration
The Req adapter accepts standard HTTPower options plus any Req-specific options:
- `timeout` - Request timeout in seconds (converted to milliseconds for Req)
- `ssl_verify` - Enable SSL verification (default: true)
- `proxy` - Proxy configuration. `:system` (the default) and `nil` both mean a direct
connection with no proxy — there is no system-proxy auto-detection. An explicit proxy
must be a Mint `{scheme, address, port, opts}` tuple, which is forwarded to
`connect_options[:proxy]`.
- `plug` - Req.Test plug for testing (e.g., `{Req.Test, MyApp}`)
## Testing
The Req adapter works seamlessly with `Req.Test` for mocking HTTP requests in tests:
# In your test
Req.Test.stub(MyApp, fn conn ->
Req.Test.json(conn, %{status: "success"})
end)
HTTPower.get("https://api.example.com",
adapter: HTTPower.Adapter.Req,
plug: {Req.Test, MyApp})
## Important
This adapter **disables Req's built-in retry logic** by setting `retry: false`.
HTTPower's own retry logic (with exponential backoff and jitter) is used instead,
ensuring consistent retry behavior across all adapters.
"""
@behaviour HTTPower.Adapter
alias HTTPower.{Adapter, Response}
# Opts this adapter consumes itself (:timeout, :ssl_verify, :proxy) plus the
# HTTPower-internal :adapter_config and :block_redirects. Dropped so they
# aren't passed raw to Req; everything else — including :pool_timeout, which
# Req forwards to its Finch pool — is caller passthrough.
@consumed_opts [:timeout, :ssl_verify, :proxy, :adapter_config, :block_redirects]
@impl true
def request(method, url, body, headers, opts) do
case HTTPower.TestInterceptor.intercept(method, url, body, headers) do
{:intercepted, result} -> result
:continue -> do_request(method, url, body, headers, opts)
end
end
defp do_request(method, url, body, headers, opts) do
timeout = Keyword.get(opts, :timeout, 60)
ssl_verify = Keyword.get(opts, :ssl_verify, true)
proxy = Keyword.get(opts, :proxy, :system)
req_opts = build_req_opts(method, url, body, headers, timeout, ssl_verify, proxy, opts)
with {:ok, response} <- safe_req_request(req_opts) do
{:ok, convert_response(response)}
end
end
defp build_req_opts(method, url, body, headers, timeout, ssl_verify, proxy, opts) do
base_opts = [
method: method,
url: url,
headers: prepare_headers(headers),
receive_timeout: timeout * 1000,
# IMPORTANT: Disable Req's built-in retry to avoid conflicts with HTTPower's retry logic
retry: false,
# IMPORTANT: Disable Req's built-in response decoding — HTTPower.Codec handles this
decode_body: false
]
# HTTPower-owned options are already stripped by HTTPower.Client before the
# adapter is called. What remains is the connection opts this adapter
# consumes (dropped here so they aren't passed raw to Req) plus any
# caller-supplied Req passthrough options (e.g. :plug for Req.Test).
additional_opts = Keyword.drop(opts, @consumed_opts)
base_opts
|> maybe_add_body(body)
|> maybe_add_ssl_options(url, ssl_verify)
|> maybe_add_proxy_options(proxy)
|> Keyword.merge(additional_opts)
|> maybe_disable_redirects(opts)
end
# HTTPower's SSRF guards (:block_private_ips / :allowed_hosts) validate only
# the initial URL. Req follows redirects automatically, so a permitted host
# could redirect to a private or disallowed target that is never re-checked.
# When Client signals active guards via :block_redirects, fail closed by
# disabling Req's redirect following — the 3xx response is returned as-is for
# the caller to handle. Applied last so it can't be overridden by a
# caller-supplied :redirect passthrough.
defp maybe_disable_redirects(req_opts, opts) do
if Keyword.get(opts, :block_redirects, false) do
Keyword.put(req_opts, :redirect, false)
else
req_opts
end
end
defp maybe_add_body(opts, nil), do: opts
defp maybe_add_body(opts, body), do: Keyword.put(opts, :body, body)
defp maybe_add_ssl_options(opts, %URI{scheme: "https"}, ssl_verify) do
ssl_opts = [verify: if(ssl_verify, do: :verify_peer, else: :verify_none)]
existing = Keyword.get(opts, :connect_options, [])
updated = Keyword.put(existing, :transport_opts, ssl_opts)
Keyword.put(opts, :connect_options, updated)
end
defp maybe_add_ssl_options(opts, _url, _ssl_verify), do: opts
# Mint (and therefore Req) has no system-proxy detection and raises a
# CaseClauseError if given :proxy :system. Treat :system as "no explicit
# proxy" (direct connection), matching the Finch adapter's behavior.
defp maybe_add_proxy_options(opts, :system), do: opts
defp maybe_add_proxy_options(opts, nil), do: opts
# Forward any explicit proxy value to Req's connect_options, which passes it
# to Mint. Mint expects a {scheme, address, port, opts} tuple and validates
# it; previously a tuple was silently dropped (only keyword lists matched),
# so a configured proxy had no effect.
defp maybe_add_proxy_options(opts, proxy) do
existing = Keyword.get(opts, :connect_options, [])
updated = Keyword.put(existing, :proxy, proxy)
Keyword.put(opts, :connect_options, updated)
end
defp prepare_headers(headers), do: Adapter.prepare_headers(headers)
defp safe_req_request(req_opts) do
case Req.request(req_opts) do
{:ok, response} -> {:ok, response}
{:error, reason} -> {:error, unwrap_transport_error(reason)}
end
rescue
error -> {:error, unwrap_transport_error(error)}
catch
error -> {:error, unwrap_transport_error(error)}
end
# Req wraps transport failures in %Req.TransportError{}; older/other paths
# may surface %Mint.TransportError{}. Extract the bare reason atom for both
# so HTTPower.Retry can classify them as retryable transport errors.
# Matched structurally to avoid a compile-time dependency on Mint.
defp unwrap_transport_error(%{__struct__: Mint.TransportError, reason: reason}), do: reason
defp unwrap_transport_error(%{__struct__: Req.TransportError, reason: reason}), do: reason
defp unwrap_transport_error(error), do: error
defp convert_response(%Req.Response{status: status, headers: headers, body: body}) do
%Response{
status: status,
headers: Adapter.normalize_response_headers(headers),
body: body
}
end
end
end