Skip to content

Recipes

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

Recipes

Practical, copyable patterns for the Cryptohopper Swift package. Every snippet is async — drop into the body of an async function on Swift 5.9+. They use only the public package surface, never internals.

The SDK is async: every resource method is async throws -> Any?. Cast results to [String: Any] or [Any] based on the endpoint contract.

.package(url: "https://github.com/cryptohopper/cryptohopper-swift-sdk", from: "0.1.0-alpha.1"),

Contents

Build a client and make a single call

import Cryptohopper
import Foundation

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

if let me = try await client.user.get() as? [String: Any] {
    print(me["email"] ?? "")
}

Client is a struct with a value-typed Transport, so passing it across actor boundaries doesn't introduce surprising shared state.

Cancel an in-flight request with Task cancellation

Every SDK call respects the cooperative cancellation of its enclosing Task. Wrap a call in a Task, hold the handle, call .cancel() from anywhere.

let work = Task {
    try await client.hoppers.list()
}

Task {
    try? await Task.sleep(nanoseconds: 2_000_000_000)
    work.cancel()
}

do {
    let hoppers = try await work.value
    print("got \(hoppers as? [Any] ?? []) hoppers")
} catch is CancellationError {
    print("cancelled")
}

Cancellation propagates to the underlying URLSessionDataTask, so the connection unwinds cleanly — no goroutine-style leaks.

Wait for a backtest to finish

Backtests run async server-side. create returns immediately with an ID; you poll get until status is terminal.

func runBacktest(
    client: Client,
    hopperId: String,
    fromDate: String,
    toDate: String
) async throws -> [String: Any] {
    guard let bt = try await client.backtest.create([
        "hopper_id": hopperId,
        "start_date": fromDate,
        "end_date": toDate,
    ]) as? [String: Any], let id = bt["id"].map(String.init(describing:)) else {
        throw CryptohopperError(code: .unknown, message: "create returned no id", status: 0)
    }

    while true {
        try Task.checkCancellation()
        guard let cur = try await client.backtest.get(id) as? [String: Any] else { break }
        if cur["status"] as? String == "completed" || cur["status"] as? String == "failed" {
            return cur
        }
        try await Task.sleep(nanoseconds: 5 * 1_000_000_000)
    }
    fatalError("unreachable")
}

The backtest rate bucket is separate (1 request per 2 seconds). 5-second polling stays well clear.

Find every open position across all your hoppers

Sequential — one request per hopper.

guard let hoppers = try await client.hoppers.list() as? [[String: Any]] else { return }

for h in hoppers {
    guard let id = h["id"].map(String.init(describing:)) else { continue }
    if let positions = try await client.hoppers.positions(id) as? [[String: Any]] {
        for p in positions {
            print("\(h["name"] ?? "?") (#\(h["id"] ?? "?")): \(p["amount"] ?? "?") \(p["coin"] ?? "?") @ \(p["rate"] ?? "?")")
        }
    }
}

For 50+ hoppers, parallelise with TaskGroup (next recipe).

Fan out with TaskGroup

guard let hoppers = try await client.hoppers.list() as? [[String: Any]] else { return }

let results = await withTaskGroup(of: (Any, [Any]).self) { group in
    for h in hoppers {
        guard let id = h["id"].map(String.init(describing:)) else { continue }
        group.addTask {
            do {
                let ps = try await client.hoppers.positions(id)
                return (h["id"] ?? id, (ps as? [Any]) ?? [])
            } catch {
                return (h["id"] ?? id, [])
            }
        }
    }
    var out: [(Any, [Any])] = []
    for await r in group { out.append(r) }
    return out
}

for (id, ps) in results {
    print("hopper \(id): \(ps.count) positions")
}

TaskGroup runs all child tasks concurrently with no built-in cap. With many hoppers you'll spike past the normal rate bucket (30 req/min) — the SDK's auto-retry will smooth that out, but for very high fan-out add a semaphore around addTask.

Detect new fills with an async loop

import Foundation

actor SeenSet {
    private var ids: Set<String> = []
    func insert(_ id: String) -> Bool { ids.insert(id).inserted }
}

let seen = SeenSet()
let hopperId = "42"

while !Task.isCancelled {
    do {
        if let orders = try await client.hoppers.orders(hopperId) as? [[String: Any]] {
            for o in orders {
                guard let id = o["id"].map(String.init(describing:)),
                      o["status"] as? String == "filled",
                      await seen.insert(id) else { continue }
                print("Fill: \(o["market"] ?? "?") \(o["type"] ?? "?") \(o["amount"] ?? "?") @ \(o["price"] ?? "?")")
            }
        }
    } catch let e as CryptohopperError {
        print("poll error: \(e.code.rawValue)")
    }
    try? await Task.sleep(nanoseconds: 10 * 1_000_000_000)
}

For production-grade fill notifications, configure the webhooks resource — push beats poll.

Pattern match on CryptohopperError.Code

do {
    _ = try await client.hoppers.get("999999999")
} catch let e as CryptohopperError {
    switch e.code {
    case .notFound:
        print("no such hopper")
    case .unauthorized, .forbidden:
        print("auth problem; IP we sent: \(e.ipAddress ?? "unknown")")
    case .rateLimited:
        print("rate limited; retry after \(e.retryAfterMs ?? 0)ms")
    case .other(let raw):
        print("unknown server code: \(raw)")
    default:
        throw e
    }
}

Code conforms to Equatable/Hashable so == works on every variant including .other(String).

Fail fast on auth errors, retry on transient ones

The SDK auto-retries 429s. For 5xx and network errors you may want a tighter retry. Auth errors should never be retried.

func withRetry<T>(_ fn: () async throws -> T, maxAttempts: Int = 3) async throws -> T {
    var lastError: Error?
    for attempt in 0..<maxAttempts {
        do {
            return try await fn()
        } catch let e as CryptohopperError {
            switch e.code {
            case .unauthorized, .forbidden, .notFound, .validationError:
                throw e
            default:
                lastError = e
                if attempt + 1 == maxAttempts { throw e }
                try await Task.sleep(nanoseconds: UInt64(500_000_000) * UInt64(1 << attempt))
            }
        }
    }
    throw lastError ?? CryptohopperError(code: .unknown, message: "retry budget exhausted", status: 0)
}

let me = try await withRetry { try await client.user.get() }

Bring your own URLSession (proxies, instrumentation)

import Foundation

// URLSession.shared.timeoutIntervalForResource defaults to 7 days — set both
// fields explicitly when bringing your own session.
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 8
config.timeoutIntervalForResource = 8
config.connectionProxyDictionary = [
    kCFNetworkProxiesHTTPEnable as AnyHashable: 1,
    kCFNetworkProxiesHTTPProxy as AnyHashable: "corp-proxy",
    kCFNetworkProxiesHTTPPort as AnyHashable: 8080,
]
let session = URLSession(configuration: config)

let client = try Client(
    apiKey: ProcessInfo.processInfo.environment["CRYPTOHOPPER_TOKEN"]!,
    httpClient: URLSessionHTTPClient(session: session)
)

For test mocking, use URLProtocol (see Mock the SDK with URLProtocol).

Tighten timeouts for short-lived workers

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

let client = try Client(
    apiKey: ProcessInfo.processInfo.environment["CRYPTOHOPPER_TOKEN"]!,
    timeout: 8,         // ~half your function budget
    maxRetries: 1       // leave room for one retry inside the function lifetime
)

A CryptohopperError with code == .timeout is much easier to handle than a process kill.

Disable the SDK's built-in retry and handle 429 yourself

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 {
    print("rate limited; server says wait \(e.retryAfterMs ?? 0)ms")
    // your custom queue / circuit breaker / etc.
}

Useful when you have your own queue, want exact backoff control, or are running inside something that already does retries (a job runner, a workflow engine).

Mock the SDK in XCTest with URLProtocol

The SDK accepts a custom URLSession via URLSessionHTTPClient. Plug in a URLProtocol subclass.

import XCTest
@testable import Cryptohopper

final class MockURLProtocol: URLProtocol {
    static var handler: ((URLRequest) -> (HTTPURLResponse, Data))?

    override class func canInit(with request: URLRequest) -> Bool { true }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
    override func startLoading() {
        guard let handler = MockURLProtocol.handler else {
            fatalError("MockURLProtocol.handler not set")
        }
        let (resp, data) = handler(request)
        client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed)
        client?.urlProtocol(self, didLoad: data)
        client?.urlProtocolDidFinishLoading(self)
    }
    override func stopLoading() {}
}

final class UserTests: XCTestCase {
    func testUserGet() async throws {
        let config = URLSessionConfiguration.ephemeral
        config.protocolClasses = [MockURLProtocol.self]
        let session = URLSession(configuration: config)

        MockURLProtocol.handler = { req in
            let resp = HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!
            let body = Data(#"{"data":{"id":42,"email":"alice@example.com"}}"#.utf8)
            return (resp, body)
        }

        let client = try Client(
            apiKey: "test",
            httpClient: URLSessionHTTPClient(session: session)
        )
        let me = try await client.user.get() as? [String: Any]
        XCTAssertEqual(me?["id"] as? Int, 42)
    }
}

The SDK pulls data out of the envelope automatically — your mock returns {"data": ...}, your assertion sees the inner value.

See also