Probe any HTTP API for its rate limits, burst ceiling, and full-scrape time. Provider-pluggable, safe by default.
Requires Python 3.12+.
pip install sondeFrom source:
git clone https://github.com/Jartan-LLC/sonde.git
cd sonde
pip install -e .For Docker, see Docker below.
Probe the Roblox asset-owners endpoint:
export ROBLOX_COOKIE="your_roblosecurity_cookie"
sonde asset-owners --asset-id 20573078 --total-items 1470000Probe GitHub stargazers:
export GITHUB_TOKEN="ghp_..."
sonde github-stargazers --owner anthropics --repo anthropic-sdk-python --total-items 5000Anonymous probing (no auth) works too -- you'll just hit lower rate limits:
sonde github-stargazers --owner torvalds --repo linux --total-items 190000Results are written to sonde_report.json by default:
sonde asset-owners --asset-id 20573078 --output my_report.jsonSonde runs five phases against the target endpoint, then combines the measurements into a safe rate estimate.
| Phase | What it does |
|---|---|
| Sanity | One request. Validates auth, reads rate-limit response headers (e.g. x-ratelimit-limit, x-ratelimit-remaining), and records items-per-page for the scrape-time estimate. |
| Sequential | Fires back-to-back requests (up to --seq-cap, default 150) until the first 429 or the cap. Measures baseline throughput and how many requests the API allows before throttling. |
| Burst | Fires N truly-concurrent requests (default sizes: 10, 20, 40, 80) via httpx on a single asyncio event loop. After the first throttled burst, measures the recovery window -- how long until requests succeed again -- via adaptive geometric backoff. |
| Sweep | Drains the rate-limit bucket, then paces requests at progressively faster intervals (default: 8s down to 0.15s) to find the fastest sustainable interval from empty. Skipped by default when authoritative rate-limit headers are present (override with --force-sweep). |
| Estimate | Combines all measurements into a recommended request interval and, if a total item count is known, a wall-clock full-scrape estimate. |
The estimate phase uses a priority ladder to determine the safe rate:
- Authoritative headers -- If the API returned
x-ratelimit-limitand a window, use those directly (e.g. 100 requests per 60s). - Swept floor -- If the sweep found a fastest sustainable interval, use that.
- Token-bucket inference -- If burst results show a clean burst size and a measured recovery window, infer the bucket rate.
- Sequential fallback -- Use the observed sequential throughput before the first 429.
- No-throttle fallback -- If nothing ever throttled, no ceiling was found, so fall back to a conservative fraction of the measured sequential throughput.
Every rung applies the safety margin (default 80%, configurable with --margin) -- the recommended pace is ~25% slower than the measured ceiling. Rung 5 has no measured ceiling, so it applies an extra 0.5 factor on top (~40% of observed throughput at the default margin).
Roblox inventory.roblox.com/v2/assets/{id}/owners -- paginated list of owners of a collectible asset.
| Option | Required | Default | Description |
|---|---|---|---|
--asset-id |
Yes | -- | Asset ID to probe (e.g. 20573078) |
--sort-order |
No | Asc | Asc or Desc |
--page-size |
No | 100 | Items per page (capped at 100) |
--total-items |
No | None | Known total owners, for wall-clock estimate |
Auth: Set ROBLOX_COOKIE (legacy web-session) and/or ROBLOX_BEARER (Open Cloud) environment variables.
GitHub api.github.com/repos/{owner}/{repo}/stargazers -- users who starred a repository.
| Option | Required | Default | Description |
|---|---|---|---|
--owner |
Yes | -- | Repository owner/org (e.g. anthropics) |
--repo |
Yes | -- | Repository name (e.g. anthropic-sdk-python) |
--page-size |
No | 100 | Items per page (capped at 100) |
--total-items |
No | None | Known stargazer count, for wall-clock estimate |
Auth: Set GITHUB_TOKEN environment variable. Without it, you get the anonymous rate limit (60 requests/hour).
- Create a new module in
src/sonde/endpoints/. - Subclass
Endpointand implementbuild_request(cursor)andparse_page(response). - Decorate with
@registerand set a uniquename(becomes the CLI subcommand). - Override
_make_provider()to return the appropriateProvider(or use the generic one for standard 200/429 + IETF headers). - Optionally implement
total_items()for scrape-time estimates,add_arguments()/from_args()for CLI options, andextra_headers()for endpoint-specific headers. - If the endpoint is paginated, call
add_pagination_args(parser, page_max=cls.MAX_PAGE)inadd_arguments()andpagination_from_args(args, page_max=cls.MAX_PAGE)infrom_args()so it gets the shared--page-size/--total-itemsflags (clamped to your endpoint's cap). - Import the new module in
src/sonde/endpoints/__init__.pyso it registers on package load.
Minimal example:
from sonde import Endpoint, RequestSpec, PageResult, register
@register
class MyEndpoint(Endpoint):
name = "my-endpoint"
help = "one-line description for --help"
def build_request(self, cursor):
return RequestSpec(url="https://api.example.com/items", params={"page": cursor or 1})
def parse_page(self, response):
data = response.json()
return PageResult(count=len(data["items"]), next_cursor=data.get("next_page"))Common options shared by all endpoints:
| Option | Default | Description |
|---|---|---|
--max-requests |
1200 | Hard global cap across all phases (safety budget) |
--seq-cap |
150 | Max sequential requests before stopping |
--skip-burst |
off | Skip the concurrent burst phase |
--burst-sizes |
10,20,40,80 |
Comma-separated list of concurrent burst sizes |
--burst-cooldown |
60.0 | Fallback seconds between bursts if the recovery window can't be measured |
--recovery-step |
0.25 | Initial poll delay when measuring the throttle window (grows geometrically) |
--recovery-max |
90.0 | Give up measuring the window after this many seconds |
--recovery-polls |
15 | Max polls during recovery measurement |
--skip-sweep |
off | Skip the sustained-interval sweep phase |
--force-sweep |
off | Run the sweep even when authoritative rate-limit headers are present |
--sweep-intervals |
8,5,3,2,1.2,0.6,0.3,0.15 |
Inter-request intervals (seconds) to test, slow to fast |
--sweep-count |
20 | Paced requests per interval after draining |
--sweep-drain |
500 | Cap on rapid requests used to empty the bucket before each interval |
--sweep-tolerance |
0.1 | Max fraction of 429s for an interval to count as sustainable |
--margin |
0.8 | Safety margin: pace at 80% of the measured max rate (0.8 = 25% slower than ceiling) |
--output |
sonde_report.json |
Path for the JSON report (use - for stdout) |
-v / --verbose |
off | Show per-request detail (sets log level to DEBUG) |
-q / --quiet |
off | Only show warnings and errors (sets log level to WARNING) |
--log-format |
plain |
Log line format: plain (message-only) or json (structured) |
-v and -q are mutually exclusive. Logs always go to stderr; the report goes to --output.
Exit codes: 0 success, 2 precondition failure (bad arguments, unwritable --output, or the endpoint returned no usable response), 1 unexpected crash, 130 interrupted.
Use --output - to write the JSON report to stdout instead of a file. Combine with -q to suppress INFO-level log noise on stderr:
sonde asset-owners --asset-id 20573078 --output - -q | jq .estimateUse --log-format json for structured log lines on stderr (keys: timestamp, level, logger, message, plus exc on error lines), useful for log aggregators or CI pipelines:
sonde asset-owners --asset-id 20573078 --log-format json 2>sonde.logBuild:
docker build -t sonde .Run (mount current directory so the report lands on the host):
docker run --rm -v "$(pwd):/data" -e ROBLOX_COOKIE sonde \
asset-owners --asset-id 20573078 --total-items 1470000docker run --rm -v "$(pwd):/data" -e GITHUB_TOKEN sonde \
github-stargazers --owner anthropics --repo anthropic-sdk-python --total-items 5000The container writes sonde_report.json to /data (the mounted volume).
pip install -e '.[dev]'Run tests and linting:
pytest
ruff check .
ruff format --check .