Commit 6c28d32
authored
feat(retries): add min_attempts and absolute_max_elapsed_time_ms to BackoffStrategy (#342)
## 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 before
`max_elapsed_time` is honored. Counts retries, not the initial attempt.
`min_attempts=2` permits 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-flight `func()` call.
Worst-case wall-clock under this cap is `absolute_max_elapsed_time_ms +
per_attempt_timeout`.
Loop changes in both sync (`retry_with_backoff`) and async
(`retry_with_backoff_async`) paths:
1. **Post-attempt cap check.** Soft cap honored only when `retries >=
min_attempts`; hard cap unconditional.
2. **Pre-sleep hard-cap check.** Refuses to sleep into a retry whose
projected start would cross the hard cap.
3. **Post-sleep verification.** Belt-and-suspenders against late wakeups
and projection rounding.
4. **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 >
0` and/or `absolute_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 monkeypatching `time.time` / `time.sleep` /
`asyncio.sleep` / `random.uniform`. Covers the slow-first-attempt +
`min_attempts` floor scenario, floor-is-not-a-ceiling semantics, hard
cap overrides floor, sleep truncation, `TemporaryError` early-return
through both caps, `PermanentError` short-circuit immunity, and
`BackoffStrategy.__init__` validation.
-
**`test_split_pdf_cache_tmp_data_chunk_request_stream_is_replay_safe`**
in `integration/test_decorators.py`: pins the body-replay invariant for
chunk requests built from open file objects (the
`split_pdf_cache_tmp_data=True` path). Iterates `request.stream` twice
directly — bypasses `request.read()` caching — so a future Speakeasy
template change that produced a single-consumption stream would fail
this test.
## Test plan
- [x] `uv run pytest _test_unstructured_client/unit/test_retries.py` —
46 pass
- [x] `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 green1 parent 3577cd6 commit 6c28d32
5 files changed
Lines changed: 645 additions & 29 deletions
File tree
- _test_unstructured_client
- integration
- unit
- src/unstructured_client/utils
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
6 | 9 | | |
7 | 10 | | |
8 | 11 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
813 | 813 | | |
814 | 814 | | |
815 | 815 | | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
| 855 | + | |
| 856 | + | |
| 857 | + | |
0 commit comments