-
Notifications
You must be signed in to change notification settings - Fork 0
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"),- Build a client and make a single call
- Cancel an in-flight request with Task cancellation
- Wait for a backtest to finish
- Find every open position across all your hoppers
- Fan out with TaskGroup
- Detect new fills with an async loop
- Pattern match on CryptohopperError.Code
- Fail fast on auth errors, retry on transient ones
- Bring your own URLSession (proxies, instrumentation)
- Tighten timeouts for short-lived workers
- Disable the SDK's built-in retry and handle 429 yourself
- Mock the SDK in XCTest with URLProtocol
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.
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.
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.
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).
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.
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.
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).
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() }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).
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.
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).
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.
Pages
Other SDKs
Resources