HTTP client for exchange API requests.
Wraps Req with circuit breaker integration, error normalization, and telemetry. All exchange HTTP communication goes through this module.
Error classification lives in Bourse.HTTP.Errors; rate-limit descriptor
shaping and header→state updates live in Bourse.RateLimiter.Shaping.
Why Manual Query Encoding?
Uses URI.encode_query/1 instead of Req's :params step because:
- Signing requires raw params — signing patterns need params before URL encoding
- Sorted encoding — some exchanges require alphabetically sorted params
- Consistency — both public and private requests use the same encoding
Features
- Circuit breaker — per-exchange, trips on 500+ and transport errors
- Error normalization — HTTP/exchange errors to
Bourse.Errorstructs - Body-level error detection — many exchanges return HTTP 200 with error in body
- HTML response detection — geo-blocks and Cloudflare return HTML instead of JSON
- Safe retry — only GET/HEAD, never POST/PUT/DELETE
Telemetry — emits
[:bourse, :request, :start | :stop | :exception]
Signed Retry Boundary
signed_request/5 keeps Req's retry policy and backoff, but attaches a
request step that obtains a fresh signature before every repeated attempt.
Req therefore never replays frozen timestamp, nonce, or deadline material.
signed_request/4 accepts an already-signed request for compatibility and
is always single-attempt. A caller-supplied :retry option cannot re-enable
retries for that frozen request.
Usage
{:ok, exchange} = Bourse.Exchange.new("bybit")
# Public endpoint
{:ok, response} = Bourse.HTTP.request(exchange, :get, "/v5/market/tickers",
params: %{"category" => "spot", "symbol" => "BTCUSDT"}
)
Summary
Functions
Makes an HTTP request to an exchange API.
Executes an already-signed request exactly once.
Executes a signed request and refreshes its signature before every retry.
Types
@type response() :: %{status: integer(), headers: response_headers(), body: term()}
HTTP response with status, headers, and decoded body
Functions
@spec request(Bourse.Exchange.t(), atom(), String.t(), keyword()) :: {:ok, response()} | {:error, Bourse.Error.t()}
Makes an HTTP request to an exchange API.
Parameters
exchange- Exchange configuration structmethod- HTTP method (:get,:post,:put,:delete)path- API endpoint path (e.g., "/v5/market/tickers")
Options
:params- Query parameters or request body (default:%{}):headers- Additional request headers (default:[]):timeout- Request timeout in milliseconds (default: fromBourse.Defaults):base_url- Override base URL (default: uses exchange.base_urls):body_encoding- Endpoint body convention from the exchange spec:plug/:adapter- Req transport override (tests / custom adapter):retry/:retry_delay/:max_retries- Req retry controls
Any other key is rejected as {:error, %Bourse.Error{type: :bad_request}}
before a request is issued. Unknown options are a caller error; they are
not forwarded to Req and they do not melt the venue circuit breaker.
Returns
{:ok, response}- Successful response with:status,:headers,:body{:error, %Bourse.Error{}}- Normalized error
@spec signed_request( Bourse.Exchange.t(), Bourse.Signing.signed_request(), String.t(), keyword() ) :: {:ok, response()} | {:error, Bourse.Error.t()}
Executes an already-signed request exactly once.
This compatibility entry point forces retry: false, including when opts
contains another retry policy, because it cannot refresh time-bound signing
material. Dispatch uses signed_request/5 instead.
@spec signed_request( Bourse.Exchange.t(), Bourse.Signing.signed_request(), String.t(), (-> Bourse.Signing.signed_request() | {:error, Bourse.Error.t()}), keyword() ) :: {:ok, response()} | {:error, Bourse.Error.t()}
Executes a signed request and refreshes its signature before every retry.
resigner reproduces the complete signed URL, headers, and body from the
original unsigned request. Req still controls whether and when to retry; the
Bourse request step replaces the time-bound signing material before the
repeated attempt reaches the adapter.