Skip to content

Commit 5dd6761

Browse files
pimfeltkampclaude
andcommitted
Add docs/: Getting Started, Authentication, Error Handling, Rate Limits
Long-form companion documentation for the Python SDK, mirroring what shipped earlier for the Node SDK. Tailored to Python idioms: - Getting Started — install via pip/uv/poetry, context-manager pattern, type-hint behaviour, common pitfalls (token missing, FORBIDDEN with IP allowlist diagnosis, custom CA bundle for corporate proxies). - Authentication — bearer flow, app_key for per-app rate-limit attribution, http_client injection (and the lifecycle contract: SDK closes only what it constructed), refresh-token retry pattern, threading model (one client serving a thread pool is fine), public- endpoint usage with placeholder api_key. - Error Handling — full code catalog with recovery advice, single CryptohopperError discriminated by .code (no per-code subclasses), match-statement pattern with literal type, transient-vs-fatal retry wrapper, structlog/loguru-friendly serialization helper. - Rate Limits — default retry behaviour, bucket overview, disable via max_retries=0, ThreadPoolExecutor concurrency cap recommendation, diagnosis flow when "always rate-limited". README gains a links line under the status blockquote pointing at all four. Same pattern as the Node SDK / CLI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b5fd07e commit 5dd6761

5 files changed

Lines changed: 493 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Official Python SDK for the [Cryptohopper](https://www.cryptohopper.com) API.
1010

1111
> **Status: 0.4.0a1** — full coverage of all 18 public API domains: `user`, `hoppers`, `exchange`, `strategy`, `backtest`, `market`, `signals`, `arbitrage`, `marketmaker`, `template`, `ai`, `platform`, `chart`, `subscription`, `social`, `tournaments`, `webhooks`, `app`.
1212
13+
**Deeper docs:** [Getting Started](docs/Getting-Started.md) · [Authentication](docs/Authentication.md) · [Error Handling](docs/Error-Handling.md) · [Rate Limits](docs/Rate-Limits.md)
14+
1315
## Install
1416

1517
```bash

docs/Authentication.md

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Authentication
2+
3+
Every SDK request (except a handful of public endpoints like `/exchange/ticker`, `/market/homepage`, and `/platform/*`) requires an OAuth2 bearer token:
4+
5+
```
6+
Authorization: Bearer <40-char token>
7+
```
8+
9+
## Obtaining a token
10+
11+
1. Log in to [cryptohopper.com](https://www.cryptohopper.com).
12+
2. Developer → Create App — gives you a `client_id` + `client_secret`.
13+
3. Complete the OAuth consent flow for your app, which returns a bearer token.
14+
15+
Options to automate step 3:
16+
17+
- **The official CLI**: `cryptohopper login` opens the consent page, runs a loopback listener, and persists the token to `~/.cryptohopper/config.json`. You can read the token from there or run the CLI and the SDK side-by-side.
18+
- **Your own code**: call the server's `/oauth2/authorize` + `/oauth2/token` endpoints directly. The CLI's implementation is small (~300 lines) and a reasonable reference.
19+
20+
## Client construction
21+
22+
```python
23+
import os
24+
from cryptohopper import CryptohopperClient
25+
26+
ch = CryptohopperClient(
27+
api_key=os.environ["CRYPTOHOPPER_TOKEN"],
28+
app_key=os.environ.get("CRYPTOHOPPER_APP_KEY"), # optional
29+
base_url="https://api.cryptohopper.com/v1", # default
30+
timeout=30.0,
31+
max_retries=3,
32+
)
33+
```
34+
35+
All keyword arguments except `api_key` are optional.
36+
37+
### `app_key`
38+
39+
Cryptohopper lets OAuth apps identify themselves on every request via the `x-api-app-key` header (value = your OAuth `client_id`). Set `app_key` on the client and the SDK adds that header automatically. This:
40+
41+
- Shows up in Cryptohopper's server-side telemetry, so you can attribute your own traffic.
42+
- Drives per-app rate limits — if two apps share a token, they get independent quotas.
43+
- Is harmless to omit. The server accepts unattributed requests.
44+
45+
### `base_url`
46+
47+
Override for staging or a local dev server. The default is `https://api.cryptohopper.com/v1`. The trailing `/v1` is part of the base; resource paths are relative to it.
48+
49+
```python
50+
ch = CryptohopperClient(
51+
api_key=token,
52+
base_url="https://api.staging.cryptohopper.com/v1",
53+
)
54+
```
55+
56+
### `http_client`
57+
58+
If you need custom transport behaviour — proxies, custom CA bundles, connection pooling tuning — pass your own `httpx.Client`:
59+
60+
```python
61+
import httpx
62+
63+
custom = httpx.Client(
64+
timeout=30.0,
65+
proxies="http://corporate-proxy.internal:3128",
66+
verify="/path/to/corporate-ca.pem",
67+
)
68+
69+
with CryptohopperClient(api_key=token, http_client=custom) as ch:
70+
...
71+
# `custom` is NOT closed by `ch.close()` — you own its lifecycle.
72+
```
73+
74+
When the SDK constructed the client itself, it owns it and closes it on `__exit__`. When you pass one in, it doesn't.
75+
76+
## IP allowlisting
77+
78+
If your Cryptohopper app has IP allowlisting enabled, requests from unlisted IPs return `403 FORBIDDEN`. The SDK surfaces this as `CryptohopperError` with `code == "FORBIDDEN"` and a populated `ip_address` field showing the IP Cryptohopper saw:
79+
80+
```python
81+
from cryptohopper.errors import CryptohopperError
82+
83+
try:
84+
ch.hoppers.list()
85+
except CryptohopperError as err:
86+
if err.code == "FORBIDDEN":
87+
print(f"Blocked from {err.ip_address}")
88+
```
89+
90+
For CI where the runner IP isn't stable, either disable IP allowlisting for that app, or route outbound traffic through a stable IP (VPN, NAT gateway, dedicated proxy).
91+
92+
## Rotating tokens
93+
94+
Cryptohopper bearer tokens are long-lived but can be revoked:
95+
96+
- Manually from the dashboard.
97+
- When the user revokes consent.
98+
99+
The SDK surfaces revocation as `UNAUTHORIZED` on the next call. There is no automatic refresh-token handling in the SDK today — if your app uses refresh tokens, handle the `UNAUTHORIZED` branch by exchanging your refresh token for a new access token, then retrying:
100+
101+
```python
102+
def with_auto_refresh(call):
103+
try:
104+
return call()
105+
except CryptohopperError as err:
106+
if err.code != "UNAUTHORIZED":
107+
raise
108+
new_token = exchange_refresh_token() # your code
109+
# `api_key` isn't mutable on the client — construct a new one.
110+
new_ch = CryptohopperClient(api_key=new_token)
111+
return call(new_ch) # retry against the fresh client
112+
```
113+
114+
The client's `api_key` is intentionally immutable. If you need to swap tokens often, construct fresh clients — the cost is small and it sidesteps subtle races where one in-flight request uses an old token while another uses the new one.
115+
116+
## Threading and concurrency
117+
118+
`CryptohopperClient` is built on `httpx.Client`, which is **safe to share across threads**. You don't need a client-per-thread; one client serving a thread pool is fine.
119+
120+
It is **not** an async client. For asyncio, wrap calls in `asyncio.to_thread`:
121+
122+
```python
123+
import asyncio
124+
125+
async def fetch_hoppers(ch):
126+
return await asyncio.to_thread(ch.hoppers.list)
127+
```
128+
129+
A first-class async client is a roadmap item; if you need it sooner, file an issue.
130+
131+
## Public-only access (no token)
132+
133+
A handful of endpoints accept anonymous calls:
134+
135+
- `/market/*` — marketplace browse
136+
- `/platform/*` — i18n, country list, blog feed
137+
- `/exchange/ticker`, `/exchange/candle`, `/exchange/orderbook`, `/exchange/markets`, `/exchange/exchanges`, `/exchange/forex-rates` — public market data
138+
139+
The SDK still requires `api_key` at construction; pass any non-empty placeholder if you only intend to hit public endpoints. The server ignores the bearer header on whitelisted routes.
140+
141+
```python
142+
ch = CryptohopperClient(api_key="anonymous") # placeholder; ignored on public routes
143+
btc = ch.exchange.ticker(exchange="binance", market="BTC/USDT")
144+
```

docs/Error-Handling.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Error Handling
2+
3+
Every non-2xx response and every transport failure raises `CryptohopperError`. Same shape across every official Cryptohopper SDK in every language.
4+
5+
```python
6+
from cryptohopper.errors import CryptohopperError
7+
8+
try:
9+
ch.hoppers.get(999_999)
10+
except CryptohopperError as err:
11+
print({
12+
"code": err.code, # "NOT_FOUND"
13+
"status": err.status, # 404
14+
"message": str(err), # human-readable
15+
"server_code": err.server_code, # numeric Cryptohopper code (or None)
16+
"ip_address": err.ip_address, # server-reported caller IP (or None)
17+
"retry_after_ms": err.retry_after_ms, # only set on 429
18+
})
19+
```
20+
21+
## Error code catalog
22+
23+
| `code` | HTTP | When you'll see it | Recover by |
24+
|---|---|---|---|
25+
| `VALIDATION_ERROR` | 400, 422 | Missing or malformed parameter | Fix the request; the message says which parameter |
26+
| `UNAUTHORIZED` | 401 | Token missing, wrong, or revoked | Re-auth; your refresh flow kicks in |
27+
| `DEVICE_UNAUTHORIZED` | 402 | Internal Cryptohopper device-auth flow rejected you | You shouldn't see this via the public API; contact support if you do |
28+
| `FORBIDDEN` | 403 | Scope missing, or IP not allowlisted | Check `err.ip_address`; add to allowlist or grant the scope on the app |
29+
| `NOT_FOUND` | 404 | Resource or endpoint doesn't exist | Check the ID; check you're using the latest SDK |
30+
| `CONFLICT` | 409 | Resource is in a conflicting state | Cancel the existing job or wait |
31+
| `RATE_LIMITED` | 429 | Bucket exhausted | The SDK auto-retries; see [Rate Limits](Rate-Limits.md) |
32+
| `SERVER_ERROR` | 500–502, 504 | Cryptohopper's end | Retry with back-off; report if persistent |
33+
| `SERVICE_UNAVAILABLE` | 503 | Planned maintenance or downstream outage | Respect `Retry-After`; retry |
34+
| `NETWORK_ERROR` || DNS failure, TCP reset, TLS handshake failure | Retry; check your network |
35+
| `TIMEOUT` || Hit the client-side `timeout` | Retry; bump `timeout` if the operation is legitimately slow |
36+
| `UNKNOWN` | any | Anything else the SDK didn't recognise | Inspect `err.status` and `str(err)` |
37+
38+
These strings are stable across SDK versions — compare with `==`, never substring-match.
39+
40+
## Catching specific codes
41+
42+
`CryptohopperError` is a single exception type with a discriminating `code` attribute. There are no per-code subclasses (deliberate — keeps the API small and matches every other Cryptohopper SDK):
43+
44+
```python
45+
try:
46+
ch.hoppers.create(data)
47+
except CryptohopperError as err:
48+
if err.code == "VALIDATION_ERROR":
49+
# Missing field. Show the user.
50+
log.warning("Bad payload: %s", err)
51+
elif err.code in {"UNAUTHORIZED", "FORBIDDEN"}:
52+
# Token problem. Re-auth.
53+
refresh_and_retry()
54+
elif err.code == "RATE_LIMITED":
55+
# SDK already retried `max_retries` times. Back off harder.
56+
sleep_long_and_retry()
57+
else:
58+
# Not an SDK-known case — log and re-raise.
59+
log.exception("Unexpected Cryptohopper error")
60+
raise
61+
```
62+
63+
The literal type `KnownCryptohopperErrorCode` is exported if you want a `match` statement with an exhaustiveness check:
64+
65+
```python
66+
from cryptohopper.errors import CryptohopperError, KnownCryptohopperErrorCode
67+
from typing import assert_never
68+
69+
def handle(err: CryptohopperError) -> str:
70+
code = err.code # narrowed by mypy if you cast it
71+
match code:
72+
case "UNAUTHORIZED" | "FORBIDDEN":
73+
return "auth"
74+
case "RATE_LIMITED":
75+
return "throttled"
76+
case "VALIDATION_ERROR":
77+
return "bad-request"
78+
case "NETWORK_ERROR" | "TIMEOUT":
79+
return "transient"
80+
case _:
81+
return "other"
82+
```
83+
84+
Note: at runtime `err.code` is `str`, not the `Literal` union — the server can return codes the SDK doesn't recognise (unprefixed pass-through). Don't write code that crashes if a new code appears.
85+
86+
## The retry surface
87+
88+
- **429 retries are automatic** up to `max_retries` (default 3). The SDK parses `Retry-After` and honours it. See [Rate Limits](Rate-Limits.md) for the algorithm.
89+
- **Everything else you handle yourself.** `SERVER_ERROR` and `NETWORK_ERROR` are often transient and benefit from retry; `UNAUTHORIZED` / `VALIDATION_ERROR` / `NOT_FOUND` never do.
90+
91+
## A robust retry wrapper
92+
93+
```python
94+
import time
95+
from collections.abc import Callable
96+
from typing import TypeVar
97+
from cryptohopper.errors import CryptohopperError
98+
99+
T = TypeVar("T")
100+
TRANSIENT = {"SERVER_ERROR", "SERVICE_UNAVAILABLE", "NETWORK_ERROR", "TIMEOUT"}
101+
102+
def with_retry(fn: Callable[[], T], *, max_attempts: int = 5, base_ms: int = 500) -> T:
103+
for attempt in range(1, max_attempts + 1):
104+
try:
105+
return fn()
106+
except CryptohopperError as err:
107+
if err.code not in TRANSIENT or attempt == max_attempts:
108+
raise
109+
wait_ms = err.retry_after_ms or base_ms * (2 ** (attempt - 1))
110+
time.sleep(wait_ms / 1000.0)
111+
raise RuntimeError("unreachable")
112+
```
113+
114+
Don't include `RATE_LIMITED` in `TRANSIENT` — the SDK already retries 429s internally. Wrapping `RATE_LIMITED` in another retry layer would multiply attempts unhelpfully.
115+
116+
## JSON-friendly error serialization
117+
118+
When you're piping SDK errors through a structured logger:
119+
120+
```python
121+
def err_to_dict(err: BaseException) -> dict[str, object]:
122+
if isinstance(err, CryptohopperError):
123+
return {
124+
"kind": "cryptohopper",
125+
"code": err.code,
126+
"status": err.status,
127+
"message": str(err),
128+
"server_code": err.server_code,
129+
"ip_address": err.ip_address,
130+
"retry_after_ms": err.retry_after_ms,
131+
}
132+
return {
133+
"kind": type(err).__name__,
134+
"message": str(err),
135+
}
136+
```
137+
138+
Plays well with structlog, loguru, and the stdlib `logging` JSON formatters.

docs/Getting-Started.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Getting Started
2+
3+
## Install
4+
5+
```bash
6+
pip install cryptohopper
7+
```
8+
9+
Requires Python 3.10 or newer. Works with `pip`, `uv`, `poetry`, `pipenv`, and any other resolver that reads `pyproject.toml`.
10+
11+
## First call
12+
13+
```python
14+
import os
15+
from cryptohopper import CryptohopperClient
16+
17+
with CryptohopperClient(api_key=os.environ["CRYPTOHOPPER_TOKEN"]) as ch:
18+
me = ch.user.get()
19+
print("Logged in as:", me["email"])
20+
```
21+
22+
The client is a context manager — using `with` ensures the underlying `httpx.Client` is closed even on exceptions. If you can't use a context manager (e.g. a long-running daemon), construct it once and call `ch.close()` on shutdown:
23+
24+
```python
25+
ch = CryptohopperClient(api_key=os.environ["CRYPTOHOPPER_TOKEN"])
26+
try:
27+
while True:
28+
do_work(ch)
29+
finally:
30+
ch.close()
31+
```
32+
33+
## Getting a token
34+
35+
Every request (except a handful of public endpoints like `/exchange/ticker`) needs an OAuth2 bearer token. Create one via **Developer → Create App** on [cryptohopper.com](https://www.cryptohopper.com) and complete the consent flow. The token is a 40-character opaque string.
36+
37+
For local dev, the simplest path is:
38+
39+
```bash
40+
export CRYPTOHOPPER_TOKEN=<your-token>
41+
```
42+
43+
In production, store the token in your secret manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, etc.) and load it at startup.
44+
45+
## Common pitfalls
46+
47+
**`ImportError: cannot import name 'CryptohopperClient'`** — the package is `cryptohopper`, not `cryptohopper-sdk`. Verify with `pip show cryptohopper`.
48+
49+
**`TypeError: CryptohopperClient: 'api_key' is required`** — you passed an empty string or `None`. Check that your env var is actually set in the process running the code:
50+
51+
```python
52+
import os
53+
print("token:", os.environ.get("CRYPTOHOPPER_TOKEN", "MISSING"))
54+
```
55+
56+
**`UNAUTHORIZED` on every call** — the token is wrong, expired, or revoked. Visit the app's page in the Cryptohopper dashboard and check the status.
57+
58+
**`FORBIDDEN` on endpoints that used to work** — IP allowlisting on the OAuth app blocked your current IP. The error includes `ip_address` so you can see what Cryptohopper saw:
59+
60+
```python
61+
from cryptohopper import CryptohopperClient
62+
from cryptohopper.errors import CryptohopperError
63+
64+
try:
65+
ch.hoppers.list()
66+
except CryptohopperError as err:
67+
if err.code == "FORBIDDEN":
68+
print(f"Blocked from {err.ip_address}")
69+
```
70+
71+
**`SSL: CERTIFICATE_VERIFY_FAILED`** — corporate proxy or self-signed root CA in your chain. Don't disable verification globally; use httpx's `verify` argument with a path to the proper CA bundle:
72+
73+
```python
74+
import httpx
75+
custom_client = httpx.Client(verify="/path/to/corporate/ca-bundle.pem", timeout=30.0)
76+
ch = CryptohopperClient(
77+
api_key=os.environ["CRYPTOHOPPER_TOKEN"],
78+
http_client=custom_client,
79+
)
80+
```
81+
82+
When you bring your own `httpx.Client`, the SDK won't close it for you — manage its lifetime yourself.
83+
84+
## Type hints
85+
86+
Every public method has full type hints. If you use mypy or pyright, the SDK plays well with strict mode:
87+
88+
```python
89+
from cryptohopper import CryptohopperClient
90+
from cryptohopper.errors import CryptohopperError
91+
92+
reveal_type(CryptohopperClient) # CryptohopperClient
93+
reveal_type(ch.hoppers.list()) # list[dict[str, Any]]
94+
```
95+
96+
Response shapes are typed as `dict[str, Any]` because Cryptohopper's API hasn't been frozen into stable models yet. If you want to layer pydantic / dataclass parsing on top, you can — the SDK won't fight you.
97+
98+
## Next steps
99+
100+
- [Authentication](Authentication.md) — deeper dive on tokens, app keys, IP whitelisting
101+
- [Error Handling](Error-Handling.md) — every error code and how to recover
102+
- [Rate Limits](Rate-Limits.md) — auto-retry, customizing back-off, high-volume patterns

0 commit comments

Comments
 (0)