Bourse.HTTP (bourse v0.7.0)

Copy Markdown View Source

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:

  1. Signing requires raw params — signing patterns need params before URL encoding
  2. Sorted encoding — some exchanges require alphabetically sorted params
  3. 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.Error structs
  • 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

Types

HTTP response with status, headers, and decoded body

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

response()

@type response() :: %{status: integer(), headers: response_headers(), body: term()}

response_headers()

@type response_headers() :: %{optional(String.t()) => [String.t()]}

HTTP response with status, headers, and decoded body

Functions

request(exchange, method, path, opts \\ [])

@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 struct
  • method - 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: from Bourse.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

signed_request(exchange, signed, base_url, opts \\ [])

@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.

signed_request(exchange, signed, base_url, resigner, opts)

@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.