From 3be3f90b218169ce80c4557a1d4b9d9ccf11a69a Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Tue, 4 Aug 2026 12:53:53 -0700 Subject: [PATCH] docs: document the two 429s (0.11.1 / cli 0.8.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RateLimitError covers two limits that want opposite handling — slow down, or wait for a running generation to finish — and the class and code (rate_limit_exceeded) are identical for both. Only the message tells them apart, so the README now quotes both sentences and says what each one means, alongside the existing "three 402s" section that makes the same point for that status. The per-minute half also documents what the message alone cannot: the counter runs on a fixed 60-second window and rejected requests count toward it too, so a retry inside the window cannot succeed. The CLI prints the API's sentence verbatim, so its README gains the same guidance with the actual terminal output. No runtime change: error_from_response already passes the message through whole. A test pins that, since it is the property the docs now depend on. Patch bumps on both packages so the READMEs reach PyPI; sonilo-cli's >=0.11.0,<0.12 pin already admits the new core. --- README.md | 23 +++++++++++++++++++++++ pyproject.toml | 2 +- sonilo-cli/README.md | 16 ++++++++++++++++ sonilo-cli/pyproject.toml | 2 +- tests/test_errors.py | 24 ++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9fed599..4e505f5 100644 --- a/README.md +++ b/README.md @@ -467,3 +467,26 @@ except PaymentRequiredError as exc: `TrialExhaustedError` subclasses `PaymentRequiredError`, so an existing `except PaymentRequiredError` keeps catching every 402 — order the handlers most-specific-first if you want to tell them apart. + +### The two 429s + +`RateLimitError` covers two separate limits that want opposite handling. +The class and `.code` (`rate_limit_exceeded`) are identical for both — only +the message tells them apart: + +- **Requests per minute** — `Rate limit exceeded: your account allows 60 + requests per minute. Rejected requests count toward the limit too, so wait + for the next minute window (up to 60 sec) rather than retrying right away. + To raise your limit, contact info@sonilo.com.` Calls are going out too fast. + The counter runs on a fixed 60-second window, so back off past the window + boundary instead of retrying inside it. +- **Concurrent generations** — `Too many concurrent generations: 5 of 5 in + progress. Wait for one to finish before starting another. To raise your + limit, contact info@sonilo.com.` Every generation slot is busy. Waiting + alone frees nothing — retry when one of your own in-flight generations + finishes, not on a timer. + +The numbers are the account's own limits; `account.services()` reports them +as `rpm_limit` and `concurrency_limit`. Email info@sonilo.com to raise +either. `.retry_after` is set only when the server sends a `Retry-After` +header, so treat it as a hint rather than something to depend on. diff --git a/pyproject.toml b/pyproject.toml index ca2d1ad..1cbd999 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.11.0" +version = "0.11.1" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/sonilo-cli/README.md b/sonilo-cli/README.md index 5ca9355..c0d082f 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -181,3 +181,19 @@ Because the summary is on stderr, `sonilo account | jq .trial` still sees clean Once an endpoint's free runs are used up, calls to it bill at the normal rate — or, if the account has never been funded, fail with `HTTP 402: ... (trial_exhausted)` until a payment method is added. That is the one 402 a retry can never fix. + +## Rate limits + +Two separate limits return `HTTP 429`, and they want opposite handling. The CLI prints the API's +own sentence, so the wording says which one you hit: + + sonilo: HTTP 429: Rate limit exceeded: your account allows 60 requests per minute. Rejected requests count toward the limit too, so wait for the next minute window (up to 60 sec) rather than retrying right away. To raise your limit, contact info@sonilo.com. (rate_limit_exceeded) + sonilo: HTTP 429: Too many concurrent generations: 5 of 5 in progress. Wait for one to finish before starting another. To raise your limit, contact info@sonilo.com. (rate_limit_exceeded) + +The first means calls are going out too fast. The counter runs on a fixed 60-second window and +rejected calls count toward it too, so wait the window out instead of retrying inside it. The +second means every generation slot is busy — waiting alone frees nothing, a running generation has +to finish first. + +`sonilo account` prints the account's own `rpm_limit` and `concurrency_limit`; the numbers above +are the standard-tier defaults. Email info@sonilo.com to raise either. diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index 9e6c5c6..9ab9c7e 100644 --- a/sonilo-cli/pyproject.toml +++ b/sonilo-cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo-cli" -version = "0.8.0" +version = "0.8.1" description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video" readme = "README.md" license = "MIT" diff --git a/tests/test_errors.py b/tests/test_errors.py index cdd2d11..5916115 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -135,6 +135,30 @@ def test_429_without_header_has_no_retry_after(): assert err.retry_after is None +# Two limits share this status and want opposite handling — slow down, or wait +# for a running generation to finish. The code is identical for both, so the +# message is the only thing that tells them apart: it has to survive whole, +# numbers and contact address included. +@pytest.mark.parametrize( + "message", + [ + "Rate limit exceeded: your account allows 60 requests per minute. " + "Rejected requests count toward the limit too, so wait for the next " + "minute window (up to 60 sec) rather than retrying right away. " + "To raise your limit, contact info@sonilo.com.", + "Too many concurrent generations: 5 of 5 in progress. Wait for one to " + "finish before starting another. To raise your limit, contact " + "info@sonilo.com.", + ], +) +def test_429_carries_the_message_through_verbatim(message): + err = error_from_response( + make_response(429, {"code": "rate_limit_exceeded", "message": message}) + ) + assert isinstance(err, RateLimitError) + assert str(err) == f"HTTP 429: {message}" + + @pytest.mark.parametrize("status", [400, 413, 422]) def test_4xx_maps_to_bad_request_with_legacy_detail(status): err = error_from_response(make_response(status, {"detail": "bad input"}))