Repository navigation
feat(retry): add pluggable backoff strategies with jitter to Python [replica of harness-sdk#4960] - #17
feat(retry): add pluggable backoff strategies with jitter to Python [replica of harness-sdk#4960]#17poshinchen wants to merge 2 commits into
Conversation
Port the TypeScript backoff library to strands-py as strands.retry: ConstantBackoff, LinearBackoff and ExponentialBackoff with none/full/equal/ decorrelated jitter, plus RetryDecision and an overridable ModelRetryStrategy.compute_retry_decision. ModelRetryStrategy stays the concrete default and keeps its seconds-based constructor and attempt budget semantics. AfterModelCallEvent now reports attempt_count. Refs strands-agents#3151
poshinchen
left a comment
There was a problem hiding this comment.
Strandly v3-reason (optimized) review
Assessment: comment
Assessment: Comment
Well-structured port of pluggable backoff/jitter to Python with thoughtful handling of backward compatibility (seconds-based args, is_retryable, budget semantics preserved from main). The deprecation re-export in _retry.py and the thorough docstrings are commendable. A few items warrant verification before merge.
Review Categories
- API design: New public abstractions (
BackoffStrategy, backoff classes,RetryDecision) require formal API bar-raising sign-off; confirm theapi/needs-reviewlabel. - Correctness: Verify sync/async dispatch of
compute_retry_decisionand attempt-budget accounting when subclasses short-circuit beforesuper(). - Robustness:
randomglobal state for jitter and silent degradation of invertedmax_delay < base_delayranges in decorrelated jitter. - Coupling: Router still reaches into private retry-strategy internals; a public reset seam would be safer.
- Testing: Prefer full-event equality over per-field assertions; add coverage for an async
compute_retry_decisionoverride.
This cleanly closes the SDK parity gap with TypeScript while respecting Python's existing retry semantics.
File-level comments:
strands-py/src/strands/retry/__init__.py: Important
Issue: This is a new public API surface (ExponentialBackoff, LinearBackoff, ConstantBackoff, BackoffStrategy, BackoffContext, JitterKind, RetryDecision) exported from strands and strands.retry. Per the API bar-raising process, new customer-facing abstractions require API review.
Suggestion: Please confirm the PR carries the api/needs-review (or api/review-complete) label. The description includes signatures and examples, which is good; a designated API reviewer should still sign off given the new extensible protocol.
| if kind == "equal": | ||
| return raw / 2 + random.random() * (raw / 2) | ||
| if last_delay is None: | ||
| return random.random() * raw |
There was a problem hiding this comment.
Important
Issue: Jitter uses the random module, whose global state is shared process-wide and not thread-safe for concurrent next_delay calls; it also can't be seeded deterministically per-strategy for reproducible tests.
Suggestion: Please consider using a per-instance random.Random() instance (optionally seedable) so jitter is isolated from global random state and testable without monkeypatching the module.
|
|
||
| class ConstantBackoff: | ||
| """Constant backoff: returns the same delay for every retry.""" | ||
|
|
There was a problem hiding this comment.
Suggestion
Issue: In the decorrelated branch, when max_delay < base_delay the max(base_delay, ...) guard forces upper == base_delay, so the jitter range collapses to zero and the delay is always base_delay — silently ignoring the inverted config.
Suggestion: Consider validating max_delay >= base_delay in __init__ (for Linear/Exponential) and raising ValueError rather than silently degrading, so misconfiguration surfaces early.
| return RetryDecision(retry=True, delay=delay) | ||
|
|
||
| def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: | ||
| """Register callbacks for AfterModelCallEvent and AfterInvocationEvent. |
There was a problem hiding this comment.
Important
Issue: compute_retry_decision mutates state (self._current_attempt += 1, self._first_failure_at) before computing the delay. A subclass override that calls super().compute_retry_decision(event) conditionally (as shown in the PR docs' TimeoutRetryStrategy) can leave _current_attempt out of sync when it returns its own decision without calling super.
Suggestion: Please verify the attempt-budget accounting stays correct across subclass overrides that short-circuit before super(), and document that overrides which retry independently should not rely on the base counter.
|
|
||
| Returns: | ||
| True if the exception should trigger a retry, False otherwise. | ||
| """ |
There was a problem hiding this comment.
Important
Issue: compute_retry_decision is declared to return RetryDecision | Awaitable[RetryDecision] (sync or async). The dispatch logic that awaits the result when it's a coroutine is in the truncated _handle_after_model_call portion and not visible here.
Suggestion: Please verify the caller uses inspect.isawaitable() (note inspect is imported) to await async overrides, and that a sync override returning a plain RetryDecision is never mistakenly awaited. A test covering an async def compute_retry_decision override would guard this contract.
| Args: | ||
| base_delay: Base delay in seconds. Defaults to 1. | ||
| max_delay: Upper bound in seconds applied before jitter. Defaults to 30. | ||
| jitter: Jitter mode. Defaults to ``"full"``. |
There was a problem hiding this comment.
Suggestion
Issue: The default jitter for LinearBackoff/ExponentialBackoff is "full", but ModelRetryStrategy's implicit default backoff uses jitter="none". This divergence is intentional (per the description) but easy to miss for users who construct a backoff explicitly expecting it to match the old no-jitter default.
Suggestion: Consider calling out this difference in the ModelRetryStrategy.backoff docstring/Args so the behavior change (no jitter by default vs. full jitter when composing a backoff) is explicit.
| # ModelRetryStrategy exposes no public seam for a budget reset yet, so a rename in | ||
| # _retry.py breaks this. | ||
| event.agent._retry_strategy._reset_retry_state() | ||
| event._restart_attempts() |
There was a problem hiding this comment.
Suggestion
Issue: The router comment (visible on line 388-390 context) notes ModelRetryStrategy exposes no public seam for a budget reset yet, so a rename in _retry.py breaks this, and _advance reaches into event.agent._retry_strategy._reset_retry_state() plus the new event._restart_attempts().
Suggestion: Please consider exposing a small public/protected reset hook on the retry strategy to remove this fragile private-attribute coupling; it will reduce the chance a future refactor silently breaks fallback budget resets.
| result = await agent.invoke_async("test prompt") | ||
|
|
||
| assert result.message["content"][0]["text"] == "Success" | ||
| assert mock_sleep.sleep_calls == [1, 2] |
There was a problem hiding this comment.
Suggestion
Issue: The test asserts only mock_sleep.sleep_calls and the result text, but AfterModelCallEvent now carries attempt_count; the hook-budget test (test_hook_requested_retries_do_not_consume_retry_budget) checks attempt_counts separately rather than asserting the full event shape.
Suggestion: Where feasible, assert the full AfterModelCallEvent equality (as test_event_loop.py now does with attempt_count=) so regressions in other event fields aren't silently missed. Per-field assertions can hide unexpected field changes.
| """Event emitted when the event loop is throttled due to rate limiting.""" | ||
|
|
||
| def __init__(self, delay: int) -> None: | ||
| def __init__(self, delay: float) -> None: |
There was a problem hiding this comment.
Suggestion
Issue: EventLoopThrottleEvent.__init__ signature changed from delay: int to delay: float. This is a widening of a public-ish event constructor; existing callers passing ints still work, but any type-checked downstream subclass/override would see a signature change.
Suggestion: Please verify this event is not part of a documented stable contract that would consider the parameter type a breaking change, and note the widening in the changelog if applicable.
| stop_response: ModelStopResponse | None = None | ||
| exception: Exception | None = None | ||
| retry: bool = False | ||
| attempt_count: int = 1 |
There was a problem hiding this comment.
Suggestion
Issue: attempt_count has a mutable-sounding sibling _restart_attempt_count set via object.__setattr__, implying this event is frozen; attempt_count itself is a public read field but _can_write only permits retry.
Suggestion: Please confirm hooks reading attempt_count cannot accidentally mutate it (good, since _can_write restricts writes), and that the 1-based semantics plus reset-on-candidate-switch behavior documented here are covered by the router test asserting [1,2,3,1,2,3].
Documentation Preview FailedThe documentation deployment encountered an error. Please check the deployment logs for more details. |
poshinchen
left a comment
There was a problem hiding this comment.
Strandly v4-reason (optimized) review — run af1746, Opus 4.8, upgraded harness (read_file + suggestion-aware reward)
Assessment: approve
Assessment: Approve
Well-structured port of pluggable backoff/jitter to Python. The separation of pure delay math (BackoffStrategy) from retry policy (ModelRetryStrategy) is clean, backward compatibility is preserved (legacy _retry.py re-export, unchanged defaults/semantics), and the mutually-exclusive backoff vs initial_delay/max_delay guard is a good call. Documentation and tests are updated alongside the code.
Review Categories
- API design: Public surface is coherent and matches the documented examples; override points are clearly described.
- Validation: Numeric backoff args (delay/base_delay/max_delay/multiplier) aren't validated at construction while
RetryDecisionvalidates the final delay — an inconsistency worth closing. - Maintainability:
compute_retry_decisioncarries non-obvious side effects for subclasses, and the router↔strategy↔event coupling for budget/attempt resets remains fragile. - Testing: Good coverage; a couple of behaviors (event attempt-count reset) would benefit from direct unit tests.
None of the comments are blocking — nice cross-SDK consistency work.
| synchronous or ``async``; the base implementation is synchronous. | ||
|
|
||
| Args: | ||
| event: The AfterModelCallEvent for the failed model call. |
There was a problem hiding this comment.
Important
Issue: compute_retry_decision is a documented public override point, yet it mutates instance state (_current_attempt, _first_failure_at, _last_delay) as a side effect. A subclass that returns early for its own cases without calling super() silently skips the budget increment, and one that calls super() for the fallthrough gets the increment — behavior that's easy to get wrong and hard to spot.
Suggestion: Consider documenting explicitly in the docstring that calling super().compute_retry_decision() is what advances the attempt budget, and that subclasses handling a case themselves bypass it. A brief note here prevents subtle budget bugs in overrides.
| def next_delay(self, context: BackoffContext) -> float: | ||
| """Return the configured delay. | ||
|
|
||
| Args: |
There was a problem hiding this comment.
Important
Issue: Constructors validate jitter but not the numeric delay args. A negative or non-finite base_delay/max_delay/delay/multiplier silently flows through to asyncio.sleep, where RetryDecision would reject the same negative delay. This is an inconsistent contract: the final sleep value is validated downstream only in some paths.
Suggestion: Validate delay/base_delay/max_delay (and multiplier) are non-negative and finite at construction time, mirroring _validate_jitter, so misconfiguration fails fast at the call site rather than mid-retry.
| if kind == "equal": | ||
| return raw / 2 + random.random() * (raw / 2) | ||
| if last_delay is None: | ||
| return random.random() * raw |
There was a problem hiding this comment.
Suggestion
Issue: In decorrelated mode, the raw argument (the capped exponential/linear value) is computed by the caller but completely ignored — the delay is derived solely from last_delay/base_delay. This is correct per the standard decorrelated-jitter algorithm, but a reader may be surprised that the exponential growth path is discarded here.
Suggestion: A one-line comment noting that decorrelated jitter intentionally ignores raw and derives the delay from last_delay would save future readers the double-take.
| result = await agent.invoke_async("test prompt") | ||
|
|
||
| assert result.message["content"][0]["text"] == "Success" | ||
| assert mock_sleep.sleep_calls == [1, 2] |
There was a problem hiding this comment.
Suggestion
Issue: test_hook_requested_retries_do_not_consume_retry_budget asserts the key behavior via two separate per-field checks (sleep_calls and attempt_counts), which is fine, but the test name and the PR's headline guarantee ("hook retries don't consume budget") would be clearer if it also asserted the throttle strategy still had its full budget available — i.e. that it reached max_attempts=3 on throttles after the two ValueError retries.
Suggestion: The current sleep_calls == [1, 2] indirectly proves this; consider a short comment tying those two sleeps to the two throttle retries so the intent survives future edits.
| # ModelRetryStrategy exposes no public seam for a budget reset yet, so a rename in | ||
| # _retry.py breaks this. | ||
| event.agent._retry_strategy._reset_retry_state() | ||
| event._restart_attempts() |
There was a problem hiding this comment.
Suggestion
Issue: The router comment (above this line, retained from main) warns that ModelRetryStrategy exposes no public seam for a budget reset yet, so a rename in _retry.py breaks this. This PR adds a second coupling: event._restart_attempts() plus the private _reset_retry_state() reach into internals across module boundaries.
Suggestion: Since this PR touches both sides, consider whether a small internal protocol (e.g. a _reset_for_candidate_switch() seam that encapsulates both the budget reset and attempt restart) would reduce the fragile cross-module coupling the existing comment already flags.
|
|
||
| def _next_attempt_count(self) -> int: | ||
| """Attempt count for the model call that follows a retry of this one.""" | ||
| return 1 if self._restart_attempt_count else self.attempt_count + 1 |
There was a problem hiding this comment.
Suggestion
Issue: _next_attempt_count / _restart_attempts encode the "reset on candidate switch" rule, but attempt_count is a public, documented field customers read in hooks. The reset semantics ("resets to 1 when model routing switches candidates") are only discoverable from the docstring, not from the event's behavior.
Suggestion: Consider adding a focused unit test on AfterModelCallEvent._next_attempt_count() covering both the normal increment and the post-_restart_attempts() reset, so the contract is pinned independently of the router integration test.
|
Live Strandly's actual comments on the original PR 4960 (copied for comparison; mentions escaped and links de-linked so nothing is pinged or cross-referenced) Review (COMMENTED):
|
Replica of
https://github.com/strands-agents/harness-sdk/pull/4960(URL in backticks to avoid a cross-reference on the original PR) for reviewer evaluation. Branches mirror the original PR's base and head commits.The review below was produced by the optimized Strandly reviewer (variant
v3-reason, optimization runaeb3ee, agent model Opus 4.8) during validation on PRs it never saw in training. Compare with the human and Strandly reviews on the original PR.