Unit test a Sume 429 retry loop in Python with a fake opener
Test a Sume rate_limited retry loop with unittest and a fake opener: retry-after is honored, the last try raises, no network, no spend. Stdlib only.

A retry loop is the code you only exercise on a bad day. If the first time it runs is a real 429 during a paid batch, a bug in it costs you either a stalled job queue or a second charge. You can test it without the network and without spending anything, if the loop takes its HTTP call as an argument instead of reaching for a global.
The Sume API sends retry-after on its 429 responses, and the error body carries code: rate_limited with details describing which budget you spent. A loop that honors that header is simple to state: wait what the server says, then try again, and stop after a fixed number of tries. Each of those clauses is one test.
Two seams make it testable
openeris the function that performs the request. In production it isurllib.request.urlopenwith a prepared request. In a test it is aMockwhoseside_effectis a list: an exception to raise, or a response to return, one per call.sleepis injected too and defaults to a no-op here. In production passtime.sleep. In a test passlist.appendand assert on what was recorded, so the suite runs in milliseconds and still proves the wait was 7 seconds.
The loop and its two tests
Everything below is in the standard library and runs as one file. HTTPError is built with the same shape the API returns: status 429, a retry-after header and a JSON body that json.load can read. The ok() helper stands in for a response object used as a context manager.
import io, json, unittest, urllib.error
from unittest import mock
def get_json(opener, url, tries=3, sleep=lambda s: None):
for attempt in range(tries):
try:
with opener(url) as r:
return json.load(r)
except urllib.error.HTTPError as e:
if e.code != 429 or attempt == tries - 1:
raise
sleep(float(e.headers.get("retry-after") or 2**attempt))
def too_many(wait):
body = io.BytesIO(b'{"error": {"code": "rate_limited"}}')
return urllib.error.HTTPError("u", 429, "Too Many", {"retry-after": wait}, body)
ok = mock.MagicMock()
ok.__enter__.side_effect = lambda: io.BytesIO(b'{"data": {"terminal": true}}')
class RetryTests(unittest.TestCase):
def test_waits_the_hinted_time(self):
opener, waits = mock.Mock(side_effect=[too_many("7"), ok]), []
self.assertTrue(get_json(opener, "u", sleep=waits.append)["data"]["terminal"])
self.assertEqual(waits, [7.0])
def test_gives_up_after_the_last_try(self):
opener = mock.Mock(side_effect=[too_many("0")] * 3)
with self.assertRaises(urllib.error.HTTPError):
get_json(opener, "u")
self.assertEqual(opener.call_count, 3)
unittest.main(argv=["t"], exit=False)What each test pins down
| Case | Why it matters |
|---|---|
| 429 with retry-after 7, then 200 | You wait what the server named instead of guessing a backoff |
| 429 on every try | The last try raises, so a failure is visible and not swallowed |
| Non-429 error (add a test) | A 402 or 400 must raise at once, since a retry cannot fix it |
| 429 without the header (add a test) | Falls back to the exponential delay |
Keep the test honest
- Use a real error body in the fixtures. Copy one from a failed call of your own, so a change in the envelope breaks the test and not production.
- Test the policy and not the transport. This suite proves when to retry. It cannot prove that a
POSTis replay-safe, which depends on sending the sameIdempotency-Keyon every attempt. - Add one test per status you decided not to retry. For Sume a
402 insufficient_creditsis not retryable and needs funds, so it should raise on the first call.
The header and budget rules are in the authentication guide. The mocking tools used here are described in the unittest.mock docs.
Sources
Related posts
More in Developers
- unsupported_media_type: video_url served as text/html or an image
Sume video trim and filter HEAD the source and refuse a declared non-video content type. What is checked, why octet-stream passes, and a runnable check.
- Valibot safeParse on a Sume job status: keep polling on bad data
Valibot's safeParse returns a result instead of throwing, so a malformed Sume status body can be logged and retried. A short schema and runnable code.
- Validate video duration and resolution in Python before you submit
Fetch GET /v1/videos/models and check duration, resolution and aspect_ratio per model in about 25 lines of Python, before a Sume video job fails.
- Veo 2.0 and Veo 3.0 shut down June 30: what model id to call now
Google retired veo-2.0 and veo-3.0 ids on 2026-06-30. See which Veo and Omni ids the Gemini docs list now, and how to move the call to Sume.
Written by Sume