Skip to content

Recipes

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

Recipes

Practical, copyable patterns for the cryptohopper Dart package. Every snippet runs as-is — drop into a Dart file and execute with dart run file.dart. They use only the public package surface, never internals.

The SDK is async: every resource method returns Future<dynamic> (decoded JSON). Use await everywhere; mix try / on CryptohopperException catch (e) to handle errors.

Until the package is on pub.dev (see PUBLISHING.md) install from git:

# pubspec.yaml
dependencies:
  cryptohopper:
    git:
      url: https://github.com/cryptohopper/cryptohopper-dart-sdk
      ref: v0.1.0-alpha.1

Contents

Build a client and clean up

The client owns an http.Client connection pool. Call close() when you're done — leaking pools holds open file descriptors.

import 'dart:io';
import 'package:cryptohopper/cryptohopper.dart';

Future<void> main() async {
  final client = CryptohopperClient(
    apiKey: Platform.environment['CRYPTOHOPPER_TOKEN']!,
  );
  try {
    final me = await client.user.get();
    print(me['email']);
  } finally {
    client.close();
  }
}

Inside Flutter, instantiate once at app startup and dispose with the rest of your services.

Wait for a backtest to finish

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

Future<Map<String, dynamic>> runBacktest(
  CryptohopperClient client, {
  required Object hopperId,
  required String fromDate,
  required String toDate,
}) async {
  final submitted = await client.backtest.create({
    'hopper_id': hopperId,
    'start_date': fromDate,
    'end_date': toDate,
  }) as Map<String, dynamic>;

  while (true) {
    final cur = await client.backtest.get(submitted['id']) as Map<String, dynamic>;
    if (cur['status'] == 'completed' || cur['status'] == 'failed') {
      return cur;
    }
    await Future<void>.delayed(const Duration(seconds: 5));
  }
}

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

final hoppers = await client.hoppers.list() as List;
for (final h in hoppers) {
  final positions = await client.hoppers.positions(h['id']) as List;
  for (final p in positions) {
    print('${h['name']} (#${h['id']}): ${p['amount']} ${p['coin']} @ ${p['rate']}');
  }
}

Sequential. With many hoppers, parallelise with Future.wait (recipe below).

Detect new fills with Timer.periodic

import 'dart:async';

final seen = <Object>{};

Timer.periodic(const Duration(seconds: 10), (_) async {
  try {
    final orders = await client.hoppers.orders(hopperId) as List;
    for (final o in orders) {
      final id = o['id'];
      if (id == null || seen.contains(id)) continue;
      if (o['status'] == 'filled') {
        seen.add(id);
        print('Fill: ${o['market']} ${o['type']} ${o['amount']} @ ${o['price']}');
      }
    }
  } on CryptohopperException catch (e) {
    print('poll error: ${e.code}');
  }
});

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

Switch on CryptohopperException codes (Dart 3)

Dart 3's switch expression makes the typed-error surface neat.

try {
  await client.hoppers.get('999999999');
} on CryptohopperException catch (e) {
  final action = switch (e.code) {
    'NOT_FOUND' => 'no such hopper',
    'UNAUTHORIZED' || 'FORBIDDEN' => 'auth problem; IP we sent: ${e.ipAddress ?? "unknown"}',
    'RATE_LIMITED' => 'rate limited; retry after ${e.retryAfterMs}ms',
    _ => null,
  };
  if (action == null) rethrow;
  print(action);
}

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.

Future<T> withRetry<T>(Future<T> Function() fn, {int maxAttempts = 3}) async {
  for (var attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn();
    } on CryptohopperException catch (e) {
      const fatal = {'UNAUTHORIZED', 'FORBIDDEN', 'NOT_FOUND', 'VALIDATION_ERROR'};
      if (fatal.contains(e.code)) rethrow;
      if (attempt + 1 == maxAttempts) rethrow;
      await Future<void>.delayed(Duration(milliseconds: 500 * (1 << attempt)));
    }
  }
  throw StateError('unreachable');
}

final me = await withRetry(() => client.user.get());

Run multiple SDK calls in parallel with Future.wait

final hoppers = await client.hoppers.list() as List;

// Issue all positions calls concurrently. The SDK is reentrant — share one
// client across awaits.
final futures = hoppers.map<Future<MapEntry<Object, List>>>((h) async {
  final positions = await client.hoppers.positions(h['id']) as List;
  return MapEntry(h['id'] as Object, positions);
});

final results = await Future.wait(futures);
for (final r in results) {
  print('hopper ${r.key}: ${r.value.length} positions');
}

Each in-flight call counts against the normal bucket (30 req/min). With 50+ hoppers, batch — Future.wait doesn't bound concurrency on its own:

Future<List<R>> mapConcurrent<T, R>(Iterable<T> items, int limit, Future<R> Function(T) fn) async {
  final out = <R>[];
  final list = items.toList();
  for (var i = 0; i < list.length; i += limit) {
    final chunk = list.skip(i).take(limit);
    out.addAll(await Future.wait(chunk.map(fn)));
  }
  return out;
}

Bring your own http.Client (instrumentation, mTLS)

import 'package:http/http.dart' as http;

class LoggingClient extends http.BaseClient {
  final http.Client _inner;
  LoggingClient(this._inner);

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) async {
    final start = DateTime.now();
    final res = await _inner.send(request);
    print('${request.method} ${request.url} -> ${res.statusCode} '
          '(${DateTime.now().difference(start).inMilliseconds}ms)');
    return res;
  }

  @override
  void close() => _inner.close();
}

final client = CryptohopperClient(
  apiKey: token,
  httpClient: LoggingClient(http.Client()),
);

When you pass httpClient, you're responsible for closing it — client.close() won't touch a client you supplied.

Tighten timeouts for short-lived workers and Flutter

Default timeout is 30 seconds. In a Flutter splash screen or a Cloud Functions invocation, that's too long.

final client = CryptohopperClient(
  apiKey: token,
  timeout: const Duration(seconds: 8),  // ~half your budget
  maxRetries: 1,                        // leave room for one retry
);

A CryptohopperException with code == "TIMEOUT" is much easier to handle than a hung UI.

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

final client = CryptohopperClient(
  apiKey: token,
  maxRetries: 0,
);

try {
  await client.hoppers.list();
} on CryptohopperException catch (e) {
  if (e.code == 'RATE_LIMITED') {
    print('rate limited; server says wait ${e.retryAfterMs}ms');
    // your custom queue / circuit breaker / etc.
  } else {
    rethrow;
  }
}

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

Mock the SDK in tests with package:http MockClient

The SDK accepts any http.Client. package:http's MockClient is the standard Dart way to test HTTP code.

import 'package:cryptohopper/cryptohopper.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:test/test.dart';

void main() {
  test('user.get returns the parsed payload', () async {
    final mock = MockClient((req) async {
      expect(req.method, 'GET');
      expect(req.url.path, '/v1/user/get');
      return http.Response('{"data":{"id":42,"email":"alice@example.com"}}', 200);
    });

    final client = CryptohopperClient(apiKey: 'test', httpClient: mock);
    addTearDown(() => client.close());

    final me = await client.user.get();
    expect(me['id'], 42);
  });

  test('retries on 429 then succeeds', () async {
    var calls = 0;
    final mock = MockClient((req) async {
      calls++;
      if (calls == 1) {
        return http.Response('{}', 429, headers: {'retry-after': '0'});
      }
      return http.Response('{"data":{"id":42}}', 200);
    });

    final client = CryptohopperClient(apiKey: 'test', httpClient: mock);
    addTearDown(() => client.close());

    final me = await client.user.get();
    expect(me['id'], 42);
    expect(calls, 2);
  });
}

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

See also