Skip to content

Recipes

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

Recipes

Practical, copyable patterns for github.com/cryptohopper/cryptohopper-go-sdk. Each snippet is self-contained — drop into a *.go file and go run it. They use only the public SDK surface, never internals.

The SDK is synchronous: every resource method takes a context.Context and returns (result, error). Cancellation, deadlines, and concurrency are your call to wire up — examples below show the idiomatic pattern for each.

Contents

Cancel an in-flight request from another goroutine

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go func() {
    <-time.After(2 * time.Second)
    cancel() // every in-flight SDK call using ctx will return immediately
}()

_, err := ch.Hoppers.List(ctx, nil)
if errors.Is(err, context.Canceled) {
    log.Println("cancelled cleanly")
}

The SDK propagates context cancellation to the underlying http.Client.Do, so cancellation is immediate — no goroutine leaks.

Per-call deadlines with context.WithTimeout

The SDK's WithTimeout(d) option sets a default for every request. For a one-off shorter deadline (e.g. a health-check endpoint), wrap the context per call.

ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel()

ticker, err := ch.Exchange.Ticker(ctx, "binance", "BTC/USDT")
if errors.Is(err, context.DeadlineExceeded) {
    log.Println("ticker timed out — back off and retry later")
}

context.DeadlineExceeded propagates as-is; the SDK does not wrap it as a *cryptohopper.Error. Compare with errors.Is, not by string.

Wait for a backtest to finish

Backtests are async server-side. Create returns immediately; you poll Get until status is terminal.

func RunBacktest(ctx context.Context, ch *cryptohopper.Client, hopperID any, from, to string) (map[string]any, error) {
    bt, err := ch.Backtest.Create(ctx, map[string]any{
        "hopper_id":  hopperID,
        "start_date": from,
        "end_date":   to,
    })
    if err != nil {
        return nil, err
    }

    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()

    id := bt["id"]
    for {
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-ticker.C:
            cur, err := ch.Backtest.Get(ctx, id)
            if err != nil {
                return nil, err
            }
            switch cur["status"] {
            case "completed", "failed":
                return cur, nil
            }
        }
    }
}

Backtests have their own rate bucket (1 request per 2 seconds). 5-second polling stays well clear.

Find every open position across all your hoppers

Sequential — one request per hopper. Simple, no concurrency.

hoppers, err := ch.Hoppers.List(ctx, nil)
if err != nil {
    return err
}

for _, h := range hoppers {
    positions, err := ch.Hoppers.Positions(ctx, h["id"])
    if err != nil {
        return fmt.Errorf("hopper %v: %w", h["id"], err)
    }
    for _, p := range positions {
        fmt.Printf("%s (#%v): %v %v @ %v\n", h["name"], h["id"], p["amount"], p["coin"], p["rate"])
    }
}

For >50 hoppers, parallelise with errgroup (next recipe).

Fan out with errgroup

import "golang.org/x/sync/errgroup"

g, gctx := errgroup.WithContext(ctx)
g.SetLimit(10) // bounded concurrency

hoppers, err := ch.Hoppers.List(ctx, nil)
if err != nil {
    return err
}

results := make([][]map[string]any, len(hoppers))
for i, h := range hoppers {
    i, h := i, h
    g.Go(func() error {
        ps, err := ch.Hoppers.Positions(gctx, h["id"])
        if err != nil {
            return err
        }
        results[i] = ps
        return nil
    })
}

if err := g.Wait(); err != nil {
    return err
}

errgroup cancels every other goroutine the moment one returns an error, so you don't waste API calls after a failure. The SDK retries 429s transparently — but if you fan out 100+ requests at once you'll still hit the bucket faster than retries can absorb. Cap concurrency with SetLimit.

Detect new fills since the last poll

seen := map[any]struct{}{}

for {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-time.After(10 * time.Second):
        orders, err := ch.Hoppers.Orders(ctx, hopperID, nil)
        if err != nil {
            log.Printf("poll error: %v", err)
            continue
        }
        for _, o := range orders {
            id := o["id"]
            if _, ok := seen[id]; ok {
                continue
            }
            if o["status"] == "filled" {
                seen[id] = struct{}{}
                fmt.Printf("Fill: %v %v %v @ %v\n", o["market"], o["type"], o["amount"], o["price"])
            }
        }
    }
}

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

Inspect typed errors with errors.As

Every non-2xx response and every transport failure is a *cryptohopper.Error. Extract it with errors.As, never type-assert directly.

import (
    "errors"
    cryptohopper "github.com/cryptohopper/cryptohopper-go-sdk"
)

_, err := ch.Hoppers.Get(ctx, "999999999")
var ce *cryptohopper.Error
if errors.As(err, &ce) {
    log.Printf("code=%s status=%d server=%d ip=%s retry_after=%v",
        ce.Code, ce.Status, ce.ServerCode, ce.IPAddress, ce.RetryAfter)

    if ce.Code == "NOT_FOUND" {
        return ErrHopperGone
    }
}

ce.Code is a stable string — compare with ==, never substring-match.

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 any](ctx context.Context, fn func() (T, error), maxAttempts int) (T, error) {
    var zero T
    for attempt := 0; attempt < maxAttempts; attempt++ {
        v, err := fn()
        if err == nil {
            return v, nil
        }

        var ce *cryptohopper.Error
        if errors.As(err, &ce) {
            switch ce.Code {
            case "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "VALIDATION_ERROR":
                return zero, err // never retry
            }
        }
        if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
            return zero, err
        }
        if attempt < maxAttempts-1 {
            select {
            case <-ctx.Done():
                return zero, ctx.Err()
            case <-time.After(500 * time.Millisecond * (1 << attempt)):
            }
        }
    }
    return zero, fmt.Errorf("retry budget exhausted")
}

Bring your own http.Client (proxies, instrumentation)

hc := &http.Client{
    Timeout: 0, // let the SDK manage timeouts via context
    Transport: &loggingRoundTripper{rt: http.DefaultTransport},
}

ch, err := cryptohopper.NewClient(
    os.Getenv("CRYPTOHOPPER_TOKEN"),
    cryptohopper.WithHTTPClient(hc),
)

type loggingRoundTripper struct{ rt http.RoundTripper }

func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
    start := time.Now()
    resp, err := l.rt.RoundTrip(req)
    log.Printf("%s %s -> %d (%s)", req.Method, req.URL.Path, resp.StatusCode, time.Since(start))
    return resp, err
}

For OpenTelemetry, use otelhttp.NewTransport(http.DefaultTransport) and pass that as the wrapped transport. For HTTP/HTTPS proxies, set Transport.Proxy = http.ProxyFromEnvironment (default on http.DefaultTransport).

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

ch, _ := cryptohopper.NewClient(
    os.Getenv("CRYPTOHOPPER_TOKEN"),
    cryptohopper.WithMaxRetries(0),
)

_, err := ch.Hoppers.List(ctx, nil)
var ce *cryptohopper.Error
if errors.As(err, &ce) && ce.Code == "RATE_LIMITED" {
    log.Printf("rate limited; server says wait %v", ce.RetryAfter)
    // 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).

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.

ch, _ := cryptohopper.NewClient(
    os.Getenv("CRYPTOHOPPER_TOKEN"),
    cryptohopper.WithTimeout(8*time.Second),  // ~half your function budget
    cryptohopper.WithMaxRetries(1),           // leave room for one retry
)

A *cryptohopper.Error with Code == "TIMEOUT" is much easier to handle than a process kill.

See also