Skip to content

Recipes

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

Recipes

Practical, copyable patterns for the cryptohopper gem. Every snippet runs as-is — paste into a .rb file and execute with ruby file.rb (after gem install cryptohopper --pre). They use only the public SDK surface, never internals.

Hash responses use string keys (raw JSON.parse output), so access is hopper["id"], not hopper[:id].

Contents

Use the client and clean up cleanly

The Ruby SDK uses Ruby's stdlib Net::HTTP — there's no long-lived connection pool to close. Just instantiate the client and use it.

require "cryptohopper"

ch = Cryptohopper::Client.new(api_key: ENV.fetch("CRYPTOHOPPER_TOKEN"))
me = ch.user.get
puts me["email"]

Within a single Sidekiq job or short-lived script, there's nothing extra to manage.

Wait for a backtest to finish

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

def run_backtest(ch, hopper_id:, from_date:, to_date:)
  bt = ch.backtest.create(
    hopper_id: hopper_id,
    start_date: from_date,
    end_date: to_date,
  )

  loop do
    cur = ch.backtest.get(bt["id"])
    return cur if %w[completed failed].include?(cur["status"])

    sleep 5
  end
end

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

ch.hoppers.list.each do |h|
  ch.hoppers.positions(h["id"]).each do |p|
    puts "#{h['name']} (##{h['id']}): #{p['amount']} #{p['coin']} @ #{p['rate']}"
  end
end

This is sequential — one request per hopper. With 50+ hoppers, parallelise with Concurrent::Promises (recipe below).

Detect new fills since the last poll

seen = Set.new

loop do
  ch.hoppers.orders(hopper_id).each do |o|
    next if seen.include?(o["id"])
    next unless o["status"] == "filled"

    seen << o["id"]
    puts "Fill: #{o['market']} #{o['type']} #{o['amount']} @ #{o['price']}"
  end

  sleep 10
end

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

Pattern-match on Cryptohopper::Error codes

Ruby 3.0+ pattern matching makes the typed-error surface very pleasant.

require "cryptohopper"

begin
  ch.hoppers.get("999999999")
rescue Cryptohopper::Error => e
  case e.code
  in "NOT_FOUND"
    puts "no such hopper"
  in "UNAUTHORIZED" | "FORBIDDEN"
    puts "auth problem; check token / scopes / IP whitelist"
    puts "  IP we sent: #{e.ip_address}" if e.ip_address
  in "RATE_LIMITED"
    puts "rate limited; retry after #{e.retry_after_ms}ms"
  else
    raise
  end
end

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

def with_retry(max_attempts: 3)
  attempts = 0
  begin
    attempts += 1
    yield
  rescue Cryptohopper::Error => e
    raise if %w[UNAUTHORIZED FORBIDDEN NOT_FOUND VALIDATION_ERROR].include?(e.code)
    raise if attempts >= max_attempts

    sleep(0.5 * (2 ** (attempts - 1)))
    retry
  end
end

me = with_retry { ch.user.get }

retry re-runs the whole begin block — exactly what you want for idempotent SDK calls.

Read your remaining backtest quota

limits = ch.backtest.limits
puts "Backtests remaining: #{limits['remaining']} of #{limits['limit']}"

For the normal and order buckets there's no explicit quota endpoint — the only signal is Retry-After on a 429 (read it via error.retry_after_ms).

Run multiple SDK calls in parallel with Concurrent::Promises

Add concurrent-ruby to your Gemfile, then:

require "concurrent-ruby"

# A single client is reentrant — share it across threads.
client = Cryptohopper::Client.new(api_key: ENV.fetch("CRYPTOHOPPER_TOKEN"))
hoppers = client.hoppers.list

# Issue every positions call in parallel, capped at 10 in flight.
pool = Concurrent::FixedThreadPool.new(10)
futures = hoppers.map do |h|
  Concurrent::Promises.future_on(pool) { [h, client.hoppers.positions(h["id"])] }
end

futures.each do |f|
  hopper, positions = f.value!
  puts "#{hopper['name']}: #{positions.size} positions"
end

pool.shutdown
pool.wait_for_termination

Each in-flight call counts against the normal bucket (30 req/min). With many concurrent calls, expect 429s — the SDK retries transparently.

Tighten timeouts for short-lived jobs (Sidekiq, cron)

Default timeout is 30 seconds. Inside a Sidekiq job with a 5-minute wall-clock budget the default is fine. Inside an AWS Lambda (15s) or Heroku request boundary, drop it.

ch = Cryptohopper::Client.new(
  api_key: ENV.fetch("CRYPTOHOPPER_TOKEN"),
  timeout: 8,        # ~half your job's budget
  max_retries: 1,    # leave headroom for one retry within the lifetime
)

A Cryptohopper::Error with code == "TIMEOUT" is much easier to handle than a process kill.

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

ch = Cryptohopper::Client.new(
  api_key: ENV.fetch("CRYPTOHOPPER_TOKEN"),
  max_retries: 0,
)

begin
  ch.hoppers.list
rescue Cryptohopper::Error => e
  if e.code == "RATE_LIMITED"
    puts "rate limited; server says wait #{e.retry_after_ms}ms"
    # your custom queue / circuit breaker / etc.
  else
    raise
  end
end

Useful when you're inside something that already does retries (Sidekiq's exponential backoff, ActiveJob retry_on, etc.) and don't want two layers of retry.

Stub the SDK in tests with WebMock

The SDK is built on Net::HTTP, so any stdlib-aware stubber works. The test suite uses WebMock.

require "webmock/rspec"

RSpec.describe "user.get" do
  let(:client) { Cryptohopper::Client.new(api_key: "test") }

  it "returns the parsed user payload" do
    stub_request(:get, "https://api.cryptohopper.com/v1/user/get")
      .with(headers: { "Authorization" => "Bearer test" })
      .to_return(
        status: 200,
        body: { data: { id: 42, email: "alice@example.com" } }.to_json,
        headers: { "Content-Type" => "application/json" },
      )

    me = client.user.get
    expect(me["id"]).to eq(42)
  end

  it "retries on 429 and respects Retry-After" do
    stub_request(:get, %r{/v1/user/get}).to_return(
      { status: 429, headers: { "Retry-After" => "0" } },
      { status: 200, body: { data: { id: 42 } }.to_json },
    )

    expect(client.user.get["id"]).to eq(42)
  end
end

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

See also