Skip to content

Rate Limits

Pim Feltkamp edited this page Apr 26, 2026 · 1 revision

Rate Limits

Cryptohopper applies per-bucket rate limits server-side. When you cross a threshold the server returns 429 Too Many Requests with a Retry-After header.

The three buckets

Bucket Limit Endpoints
normal 30 requests / minute Most reads (hoppers.list, user.get, etc.)
order 8 requests / 8-second window hoppers.buy, hoppers.sell
backtest 1 request / 2 seconds backtest.create, backtest.get, …

Each bucket has its own counter. Hitting the order limit doesn't slow down your normal reads.

What the SDK does for you

By default maxRetries is 3. On a 429 the SDK:

  1. Reads the Retry-After header (in seconds — converted to ms internally).
  2. Sleeps for that duration via Task.sleep.
  3. Retries the same request.
  4. Bubbles the error after maxRetries attempts as a CryptohopperError with code == .rateLimited and retryAfterMs populated.

Backoff isn't exponential — Retry-After is the server's authoritative wait. Doubling on top of it just makes you slower without reducing load (the server already accounted for fairness when picking the value).

do {
    _ = try await client.hoppers.list()
} catch let e as CryptohopperError where e.code == .rateLimited {
    print("Hit the limit \(e.retryAfterMs ?? 0)ms after \(3) retries.")
}

Disabling retries

let client = try Client(
    apiKey: ProcessInfo.processInfo.environment["CRYPTOHOPPER_TOKEN"]!,
    maxRetries: 0
)

do {
    _ = try await client.hoppers.list()
} catch let e as CryptohopperError where e.code == .rateLimited {
    // Handle 429 yourself — common when you have your own queue
    // or are running inside a workflow engine that already retries.
    try await Task.sleep(nanoseconds: UInt64(e.retryAfterMs ?? 1000) * 1_000_000)
}

Reading your remaining backtest quota

Backtests have an explicit quota endpoint:

if let limits = try await client.backtest.limits() as? [String: Any] {
    print("Backtests remaining: \(limits["remaining"] ?? "?") of \(limits["limit"] ?? "?")")
}

For normal and order, the only signal is the Retry-After header on 429.

Per-app vs per-user limits

The normal and order buckets are per-OAuth-token by default. If you pass appKey: <your_client_id> at construction time, the server applies a separate per-app bucket — so your app doesn't share rate-limit budget with every other unattributed caller.

let client = try Client(
    apiKey: ProcessInfo.processInfo.environment["CRYPTOHOPPER_TOKEN"]!,
    appKey: ProcessInfo.processInfo.environment["CRYPTOHOPPER_APP_KEY"]
)

This is the same pattern in every Cryptohopper SDK.

Bounding concurrent requests

If you fan out many requests at once (say, polling positions for 100 hoppers), you'll spike past the bucket faster than retries can absorb. Cap concurrency with a semaphore or a TaskGroup:

let semaphore = AsyncSemaphore(value: 10)

await withTaskGroup(of: Void.self) { group in
    for h in hoppers {
        group.addTask {
            await semaphore.withPermit {
                _ = try? await client.hoppers.positions(h["id"] as! String)
            }
        }
    }
}

(AsyncSemaphore is in swift-async-algorithms or similar — pick the one that fits your dep budget.)

For a copy-paste version using only stdlib, see Recipes#fan-out-with-taskgroup.

Tighten timeouts on short-lived workers

In an AWS Lambda-style invocation (15s) the default 30-second SDK timeout outlives the worker, leading to confusing "function killed" errors instead of clean .timeout errors.

let client = try Client(
    apiKey: token,
    timeout: 8,         // ~half your function budget
    maxRetries: 1       // leave room for one retry inside the function lifetime
)

See also

  • Error Handling — the .rateLimited code in the broader taxonomy
  • Recipes — copyable patterns including bounded fan-out and custom retry wrappers

Clone this wiki locally