Python client for Spinneret, the control plane for fleets that share scarce, rate-limited credentials and egress. It leases one identity (an API key, a token, an account session, a cookie jar, a device fingerprint) and one proxy per request, turns the reports workers send back into cooldowns, bans, health scores and circuit breaking, and distributes versioned configuration and secrets to the same fleet.
- Sync (
Client) and asyncio (AsyncClient) clients on top ofhttpx - Typed pydantic v2 models for every node API message
- Lease context managers that merge credentials and proxies into
httpxarguments and release the lease with the last report (orLeaseService/Releasewhen nothing was reported) - Background reporters that batch reports (every 200 ms or 100 reports, bounded queue)
- Config watchers with long polling, change callbacks and atomic local snapshots
- Typed exceptions carrying the server reason and retry hint
Requires Python 3.10+, httpx>=0.27 and pydantic>=2.6.
The SDK is not published on PyPI, and the spinneret project there is an unrelated package:
pip install spinneret fetches that, not this SDK. Install it from GitHub at a release tag,
normally the one your server runs; replace v0.1.5 below with that tag:
pip install "spinneret @ git+https://github.com/TikHub/Spinneret@v0.1.5#subdirectory=sdk/python"
# + encrypted snapshots of configs that reference secrets
pip install "spinneret[crypto] @ git+https://github.com/TikHub/Spinneret@v0.1.5#subdirectory=sdk/python"The git+ form needs git where pip runs. Without it, in a python:*-slim image for example,
install the tag's source archive instead:
pip install "spinneret @ https://github.com/TikHub/Spinneret/archive/refs/tags/v0.1.5.tar.gz#subdirectory=sdk/python"Without the shell quotes, each of these requirements also works as a line in requirements.txt.
From a checkout of the repository, run from its root:
pip install ./sdk/python # SDK
pip install './sdk/python[crypto]' # + encrypted snapshots of configs that reference secrets
pip install -e './sdk/python[dev]' # editable, with the test and lint tools, to work on the SDKSOCKS proxies returned by Spinneret need pip install 'httpx[socks]'.
| Variable | Meaning | Default |
|---|---|---|
SPINNERET_URL |
Server base URL, e.g. https://spinneret.internal |
required |
SPINNERET_TOKEN |
Node API token (spn_...) |
required |
SPINNERET_NODE |
Node name sent as X-Spinneret-Node |
host name |
SPINNERET_CACHE_DIR |
Directory of config snapshots | ~/.spinneret/cache |
Explicit arguments win over the environment:
client = spinneret.Client("https://spinneret.internal", "spn_xxx", node="crawler-hk-03")import httpx
import spinneret
with spinneret.Client() as client:
with client.lease(site="shop", client="web", uri="/api/v1/search") as lease:
with httpx.Client(**lease.httpx_kwargs()) as http:
response = http.get("https://target.example.com/api/v1/search")
lease.report_response(
response, markers=["empty_list"] if not response.json().get("data") else []
)Async:
async with spinneret.AsyncClient() as client:
async with client.lease(site="shop", client="web", uri="/api/v1/feed") as lease:
async with httpx.AsyncClient(**lease.httpx_kwargs()) as http:
response = await http.get("https://target.example.com/api/v1/feed")
lease.report_response(response)See examples/basic_usage.py for a complete node.
client.lease(site, client, uri="", *, endpoint_group="", session_key="", wait_ms=0, flush_on_exit=False, raise_on_release_error=False) returns a context manager that calls
LeaseService/Acquire on entry.
Inside the block:
| Member | Description |
|---|---|
lease_id, identity_id, info, expires_at |
Lease metadata (info.probe, info.sticky, ...) |
credential |
cookies, cookie_header, headers, query, json_value (JSON key json), values |
proxy |
proxy_id, url, kind, region or None |
httpx_kwargs(headers=, params=, cookies=) |
dict(headers, cookies, params, proxy) for httpx.Client(...); explicit arguments override credential values; cookie_header becomes a Cookie header when the credential has no cookie map |
report(status, *, latency_ms, markers, business_code, error_kind, release, **fields) |
Queue a report (uri, method, outcome_hint, response_bytes, started_at, finished_at, report_id are optional fields) |
report_response(response, *, markers, business_code, release) |
Report an httpx.Response (status, method, path, latency, size) |
report_exception(exc, *, markers, release) |
Report a failed request with error_kind from classify_exception |
renew(extend_ms=0) |
Extend the lease (await on the async lease) |
release() |
End the lease now (same as leaving the block without an exception; await on the async lease) |
Release semantics:
- Reports are queued on the client's background reporter. The most recent report is held back
until the next report or the end of the block, so that it can carry
release: true. - On exit the last report is sent with
release: true, also when the block raised. When nothing was reported, the lease is released withLeaseService/Release({"lease_id": ...}); no report is invented, so report failed requests yourself (report_exception) if the server should count them. report(..., release=True)releases immediately; later reports raiseLeaseReleased.flush_on_exit=Truewaits (up toReporterOptions.close_timeout) until the reporter queue is delivered.- Release failures do not escape the block by default. A
Releaseerror with reasonlease_released,lease_unknownorlease_expiredmeans the lease has already ended and is logged at debug level; other errors (and failed direct report deliveries) are logged at warning level. Withraise_on_release_error=Truethose otherReleaseerrors are raised from thewithstatement and fromrelease(), unless the block itself raised (its exception is never replaced). - One lease can serve several requests (pagination): report every request, the last one releases.
report_id defaults to a random UUID4, finished_at to now and started_at to
finished_at - latency_ms. business_code accepts numbers and strings; error_kind must be empty
or one of the values listed under Error kinds. The reported uri is reduced to
the request path (query string and fragment removed, at most 2048 characters): the server matches
endpoint groups on the path only, and query parameters often carry signed credential values. A
407 response is reported with error_kind="proxy_auth".
Direct calls are available too: acquire, acquire_batch(site, client, count, ...), renew,
release, report(reports) (1..500 reports, synchronous).
client.reporter (Reporter thread / AsyncReporter task) batches reports:
- sends when 100 reports are queued or the oldest waited 200 ms, at most 500 per call;
- bounded queue of 10 000 reports: when full the oldest reports are dropped, counted in
reporter.stats.droppedand logged (throttled); - transport errors,
unavailable,internal,deadline_exceededandresource_exhaustedare retried with jittered exponential backoff (0.5 s .. 30 s); other failures drop the batch; - reports rejected by the server are dropped, logged and passed to
on_rejected; flush(timeout)sends the queue now;close(timeout)(also called byclient.close()) delivers what it can withinclose_timeout(5 s) and stops. The sync reporter also closes at interpreter exit; with asyncio alwaysawait client.aclose().- a worker that is no longer running (a thread in a process created with
os.fork(), or a task of an event loop that has ended) is restarted on the nextsubmit,flushorclose.
options = spinneret.ReporterOptions(
flush_interval=0.2,
batch_size=100,
max_queue_size=10_000,
on_rejected=lambda r: log.warning("rejected %s", r.reason),
)
client = spinneret.Client(reporter_options=options)def on_change(item: spinneret.ConfigItem) -> None:
reload_settings(item.content)
with client.config_watcher(
["crawler/search.json", ("_runtime", "breakers")], on_change=on_change
) as watcher:
item = watcher.get("crawler", "search.json")
changed = watcher.wait_for_change("crawler", "search.json", timeout=60)start()loads the items withBatchGetConfig; a thread (or asyncio task) then long-pollsWatchConfig(timeout_ms30 000, HTTP read timeouttimeout_ms + 5 s), stores the latest items and invokes the callbacks for every changed item (initial values included). The loop ends when the client is closed.- Every successfully fetched non-secret item is written atomically (temp file +
fsync+ rename, mode0600) to<cache_dir>/<host>/<namespace>/<group>/<key>.json. - When the server is unavailable at start (transport error,
unavailable,internal,deadline_exceeded,resource_exhaustedor another 5xx), the snapshots are loaded instead andwatcher.from_snapshotisTrueuntil the server answers. Authorization and validation errors are raised bystart(). - Secret items are never written to disk in plain text: by default they are kept in memory only
and any older snapshot of the item is removed. The server resolves
${secret:...}references before delivery and setsConfigItem.has_secret_refs(JSONhas_secret_refs) on every item whose published content contained such references; the watcher treats these items as secret automatically. Content that still contains an unresolved${secret:marker is treated as secret too. treat_as_secretis an additional override for items that embed sensitive values directly instead of referencing secrets, e.g.treat_as_secret=lambda item: item.group == "signing". It can only add secret items, never exempt an item flagged by the server; a predicate that raises counts as true.- With
cache_secrets=Truesecret items are written encrypted with AES-256-GCM using a key derived from the API token with HKDF-SHA256 (fresh salt and nonce per file). The standard library has no AES-GCM, so this requires thecryptographypackage (thecryptoextra installs it); without itcache_secrets=TrueraisesConfigurationError. Snapshots encrypted with a previous token cannot be read after token rotation. snapshots=Falsedisables the cache;cache_dir=overrides the directory.
One-off reads: get_config(group, key), batch_get_config(items), watch_config(items)
(timeout_ms=0 uses the server default wait of 30 s) and get_secret(path, version=0).
All errors derive from SpinneretError with code, reason, message, retry_after_ms
(retry_after in seconds) and http_status.
| Exception | Code / reason | What the node should do |
|---|---|---|
Unauthenticated |
unauthenticated (token_invalid, ...) |
stop and alert |
PermissionDenied |
permission_denied (scope_missing) |
stop and alert |
InvalidArgument |
invalid_argument (site_unknown, uri_invalid, ...) |
fix the caller |
NoIdentityAvailable / NoProxyAvailable |
resource_exhausted |
wait retry_after, retry |
ResourceExhausted |
resource_exhausted (rate_limited, ...) |
wait retry_after |
CircuitOpen / SitePaused |
unavailable |
pause the endpoint group / site |
Unavailable |
unavailable (rebuilding, ...) |
retry later |
LeaseUnknown |
not_found (lease_unknown) |
acquire again |
LeaseReleased / LeaseExpired |
failed_precondition |
acquire again |
NotFound, AlreadyExists, Aborted, DeadlineExceeded, Unimplemented, InternalError |
matching code | |
TransportError (subclass of Unavailable) |
no response; error_kind set |
retry later |
ConfigurationError |
invalid SDK configuration | fix the configuration |
ReporterClosedError |
report submitted after close | |
FailedPrecondition (reason client_closed) |
call on a closed client | create a new client |
Responses without a Connect error body (for example a load balancer page) are mapped from the HTTP status following the Connect protocol.
- Timeouts: connect 3 s, read 10 s (plus
wait_msfor acquire), write 10 s, pool 10 s (spinneret.Timeouts). RetryPolicy(max_retries=2): transport errors andunavailableresponses are retried with jittered exponential backoff;circuit_openandsite_pausedare never retried; server retry hints abovemax_retry_after(5 s) are raised instead of waited for.AcquireandAcquireBatchare not idempotent: they are retried only when the request provably never reached the server (connection refused, connect or pool timeout) or when the server answeredunavailable. Ambiguous failures (read timeout, connection reset) are raised.overloaded(unavailable) means the server was at its acquire concurrency limit and shed the call before attempting it. No Redis command was issued, so it is retryable like any otherunavailable: the retry honoursSpinneret-Retry-After-Ms, which the server jitters into 100–200 ms. It is notno_identity_available— the identity pool was never consulted — and needs no SDK change.spinneret.NO_RETRYdisables retries.
spinneret.classify_exception(exc) maps request exceptions to report error_kind values:
timeout, conn_reset, conn_refused, proxy_auth, tls, dns or other. It understands
httpx exceptions (including their causes), socket/SSL errors and common messages. An
httpx.HTTPStatusError yields "" (report the status instead) except 407 (proxy_auth).
Create clients after forking worker processes (for example in the worker start-up hook of
gunicorn or uvicorn): httpx connection pools must not be shared between processes. An
AsyncClient belongs to the event loop it is used on; create one per loop.
The SDK logs through logging.getLogger("spinneret") (children spinneret.reporter,
spinneret.lease, spinneret.config) with a NullHandler installed. It never logs tokens,
credentials, proxy URLs, config contents or secret values, and model reprs hide those fields.
cd sdk/python
python3 -m venv .venv && . .venv/bin/activate
pip install -e '.[dev]'
pytest -q --cov=spinneret
ruff check . && ruff format --check .
mypy srcTests use respx and httpx.MockTransport and never access the network.