The shared HTTP request pipeline: rate limiting, retry, logging, error shaping and the generic authentication schemes.
What it deliberately does not know
No venue appears in this module. It once carried a Coinbase JWT builder and a
case provider do table mapping "coinbase" and "gemini" to their own rate-limit
header parsers. Both moved into their venue packages, because a venue fact living in
shared code is a second place that can be wrong about that venue — and it was: every
provider except Coinbase once fell through to Coinbase's socket codec, so one venue's
socket spoke another's protocol at its own endpoint and delivered nothing for as long
as that stood.
What is left is generic: :hmac_sha256, :basic and :bearer auth, the request
pipeline, query-string building, response parsing, retry, and a rate-limit header
parser that reads only the conventional x-ratelimit-* shape. A venue whose scheme or
headers differ supplies its own — see build_auth_headers/5 and
parse_rate_limit_headers/1.
The limiter is resolved at call time
Never at compile time. Application.compile_env/3 would freeze whichever limiter the
consumer configured when this dependency was compiled, so a consumer changing it
later would either have to recompile or get a boot-time mismatch. Resolution goes
through DpExchange.Core.Config, so a consumer's async: true test can swap the
limiter for its own process tree without configuring it for every test beside it.
Neither acquire nor check fills the bucket
They answer "is there capacity" and, for acquire, wait until there is. Something has to report what actually left, or the bucket stays empty and every check passes. This module records on behalf of every request it makes; a venue package issuing its own HTTP calls must record for itself. One that did not acquired before every request and recorded none, so its ceiling bound nothing: 395 calls per 60s against a documented 300, while the budget panel read 83/240.
Summary
Types
How to authenticate: one of the generic schemes, or a venue's own builder.
A response as it comes back from the pipeline.
Why a request did not produce a response.
Functions
Build authentication headers for API requests.
Convenience function for GET requests with query parameters.
Parses rate-limit information from response headers, for the conventional
x-ratelimit-limit / x-ratelimit-remaining / x-ratelimit-reset shape only.
Make an HTTP request with provider-specific and account-aware rate limiting.
Types
@type account_id() :: String.t()
@type auth_scheme() :: :hmac_sha256 | :basic | :bearer | (http_method(), String.t(), body(), map() -> headers())
How to authenticate: one of the generic schemes, or a venue's own builder.
The function form is the hook that replaced a per-venue branch. The spec named only
atom() for a while after the hook was added, so a venue passing a builder — the whole
point of the hook — was told by dialyzer that the call "breaks the contract". Adding a
capability without widening its spec makes the tool argue against the feature.
@type body() :: String.t() | nil
@type http_method() :: :get | :post | :put | :delete
A response as it comes back from the pipeline.
body is whatever the transport decoded, not a string. Req decodes JSON into a map
or a list before this module sees it, and only leaves a binary when it could not.
It was declared String.t(), and that was not a harmless inaccuracy: dialyzer then
concluded that any consumer matching a decoded body was matching something impossible,
so every function reachable only through that branch was reported as unreachable
dead code. A venue package's mix dialyzer failed with Function decimal/1 will never be called about a function called on every price it parses.
The application this pipeline came from hit the same class of problem from the other
end and said so in a comment: a missing clause "poisoned dialyzer's success typing for
every HttpClient.request/5 caller — the whole chain got narrowed to {:error, _}
only." A wrong type is worse than a missing one; it makes the tool confidently wrong,
and the reader believes it.
@type options() :: keyword()
@type provider() :: String.t()
@type rate_limited_request_options() :: keyword() | %{ provider: provider(), account_id: account_id(), user_id: user_id(), operation: String.t() }
Why a request did not produce a response.
Three shapes, and the spec used to name only the first — which made dialyzer tell every consumer that its handling of the other two was unreachable dead code.
String.t()— a plain message, when no:providerwas given.{:exchange_error, provider, reason}— the same message tagged with the venue, which is what a caller gets whenever it passes:provider, i.e. almost always.
Rate limiting arrives as a two-element error, not a three-element one
This spec used to advertise {:error, :rate_limited, retry_after: seconds} as a third
return shape. request/5 never returns it. Both rate-limit paths convert to a
two-element error before returning, deliberately and for recorded reasons:
- a venue 429 becomes
"Rate limited by the venue — retry after Ns", because a three-element tuple reaching acasewritten for two-element ones crashed 152 collector tasks in one night; - our own limiter refusing becomes
"Throttled by our own rate limiter (not the venue)", because the two used to share wording and a self-inflicted refusal was read as a flaky venue for weeks.
Both keep the retry interval in the message. The spec is corrected here rather than the behaviour: a spec that names a shape the function cannot return sends dialyzer after every caller that handles it, reporting correct code as unreachable — which is exactly what it did to the Gemini package's rate-limit clause. Found 2026-08-28.
@type user_id() :: String.t()
Functions
@spec build_auth_headers(http_method(), String.t(), body(), map(), auth_scheme()) :: headers()
Build authentication headers for API requests.
Parameters
method: HTTP methodpath: API endpoint pathbody: Request bodycredentials: API credentialsauth_type: Authentication type (:hmac_sha256, :basic, :bearer)
Returns
- List of authentication headers
@spec get(String.t(), keyword() | map(), rate_limited_request_options()) :: {:ok, any()} | {:error, String.t()}
Convenience function for GET requests with query parameters.
Parameters
url: Base URL for the requestparams: Query parameters as keyword list or mapopts: Additional options
Returns
{:ok, parsed_json}on success{:error, reason}on failure
Parses rate-limit information from response headers, for the conventional
x-ratelimit-limit / x-ratelimit-remaining / x-ratelimit-reset shape only.
Returns nil when the headers do not carry it. nil means "this response did not
say", never "there is no limit" — a caller must not read an absent header as
headroom.
This used to take a provider name and dispatch on it, with branches for two venues' bespoke headers. Those branches are venue knowledge and moved into the venue packages (D-C); a venue whose headers differ parses them itself, and does not need this function to have heard of it.
@spec request( http_method(), String.t(), headers(), body(), rate_limited_request_options() ) :: {:ok, http_response()} | {:error, request_error()}
Make an HTTP request with provider-specific and account-aware rate limiting.
Parameters
method: HTTP method (:get, :post, :put, :delete)url: Full URL for the requestheaders: List of HTTP headersbody: Request body (for POST/PUT requests)opts: Additional options including rate limiting context
Options
:timeout- Request timeout in milliseconds (default: 30_000):retry_attempts- Number of retry attempts (default: 3):retry_delay- Base delay between retries in milliseconds (default: 1000):log_requests- Whether to log requests (default: true):provider- Provider name for rate limiting (required for rate limiting):account_id- Account ID for account-aware rate limiting (optional):user_id- User ID for additional isolation (optional):operation- Operation type for fine-grained rate limiting (default: "default"):raw_status- Return{:ok, response}for a 4xx instead of an error string (default:false). A venue package needs this to tell a refusal from an error: the contract makes{:refused, reason}permanent and{:error, reason}possibly transient, and the venue states which in the 4xx status and body. Without it that evidence is flattened into a message and the venue has to string-match its way back to it. 5xx is unaffected — a server error is not a venue's considered answer.
Returns
{:ok, http_response()}on success, and on a 4xx whenraw_status: true{:error, reason}on failure