feat(retries): add min_attempts and absolute_max_elapsed_time_ms to BackoffStrategy
Summary
retry_with_backoff_async currently checks now - start > max_elapsed_time before sleeping. When RetryConfig.max_elapsed_time is set to e.g. 5 min and the per-attempt httpx client timeout is 30 min, any attempt that runs longer than the budget blows it on attempt 1 — zero retries fire on subsequent transient errors. Closes that short-circuit without re-introducing the unbounded retry loops that recent budget tightening was meant to prevent.
What changes
New optional fields on BackoffStrategy (default values preserve existing behavior):
min_attempts: int = 0— minimum retry attempts that must fire beforemax_elapsed_timeis honored. Counts retries, not the initial attempt.min_attempts=2permits 1 initial + at least 2 retries (3 total attempts) before the soft cap can cut the loop.absolute_max_elapsed_time_ms: int | None = None— cap on when a new retry can START. Does NOT interrupt an in-flightfunc()call. Worst-case wall-clock under this cap isabsolute_max_elapsed_time_ms + per_attempt_timeout.
Loop changes in both sync (retry_with_backoff) and async (retry_with_backoff_async) paths:
- Post-attempt cap check. Soft cap honored only when
retries >= min_attempts; hard cap unconditional. - Pre-sleep hard-cap check. Refuses to sleep into a retry whose projected start would cross the hard cap.
- Post-sleep verification. Belt-and-suspenders against late wakeups and projection rounding.
- Helper extraction (
_cap_hit_after_attempt,_raise_or_return_after_cap) dedupes the soft/hard cap logic between sync and async.
Validation in BackoffStrategy.__init__ rejects min_attempts < 0, absolute_max_elapsed_time_ms <= 0, and hard cap below soft cap.
.genignore updated to preserve these fields across future Speakeasy regens, matching the general.py and users.py precedent.
Design choices
- Hard cap is a retry-start cap, not a wall-clock bound. In-flight
func()cannot be interrupted from the retry loop. Consumers should pair the cap with a sensible per-attempt timeout to keep the worst case bounded. - Defaults preserve existing behavior so this change is non-breaking for every existing consumer. Consumers opt in by setting
min_attempts > 0and/orabsolute_max_elapsed_time_ms.
Tests
49 tests pass (46 unit + 3 split-PDF retry integration). New coverage:
- T1–T14 in
_test_unstructured_client/unit/test_retries.py: fake-clock harness monkeypatchingtime.time/time.sleep/asyncio.sleep/random.uniform. Covers the slow-first-attempt +min_attemptsfloor scenario, floor-is-not-a-ceiling semantics, hard cap overrides floor, sleep truncation,TemporaryErrorearly-return through both caps,PermanentErrorshort-circuit immunity, andBackoffStrategy.__init__validation. test_split_pdf_cache_tmp_data_chunk_request_stream_is_replay_safeinintegration/test_decorators.py: pins the body-replay invariant for chunk requests built from open file objects (thesplit_pdf_cache_tmp_data=Truepath). Iteratesrequest.streamtwice directly — bypassesrequest.read()caching — so a future Speakeasy template change that produced a single-consumption stream would fail this test.
Test plan
-
uv run pytest _test_unstructured_client/unit/test_retries.py— 46 pass -
uv run pytest _test_unstructured_client/integration/test_decorators.py::test_split_pdf_*retry* _test_unstructured_client/integration/test_decorators.py::test_split_pdf_cache_tmp_data_*— 3 pass - CI green