feat: add retry_after and delay_multiplier to LLMError for generalized backoff - #24
Merged
Brian Krabach (bkrabach) merged 5 commits intoFeb 28, 2026
Merged
Conversation
Add retry_after (float | None) and delay_multiplier (float) as keyword-only parameters on LLMError.__init__, with defaults None and 1.0 respectively. - Update LLMError docstring to document new attributes - Update LLMError.__repr__ to show retry_after when not None, delay_multiplier when != 1.0 - RateLimitError now forwards retry_after via super().__init__ instead of local assignment - ProviderUnavailableError accepts and forwards both new kwargs - All changes backward compatible (default values preserve existing behavior) - 12 new tests: TestRetryAfterOnBaseClass (7) + TestReprWithNewFields (5) 🤖 Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
- Apply error's delay_multiplier after max_delay cap (can exceed max_delay) - Generalize retry_after from RateLimitError-only to any LLMError - Update honor_retry_after docstring to reflect generic behavior - Order: base_delay → cap → multiplier → retry_after floor → jitter Tests: TestDelayMultiplier (3), TestRetryAfterOnAnyError (3)
…try_after 🤖 Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Moved import asyncio from inside the test_honor_retry_after_false_ignores_retry_after test body to the top-level imports for consistency with the rest of the file. Code quality review suggestion. 🤖 Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Patched asyncio.sleep in test_retry_after_can_exceed_max_delay to avoid a ~5 second real sleep during CI. The test now uses unittest.mock.patch on asyncio.sleep (same pattern as the nearby test_retry_after_wins_over_multiplied_delay) while still asserting the correct delay value via the on_retry callback. Simplified the test to use AsyncMock side_effect instead of a manual closure. Tests now run in 2.5s instead of ~7.5s. 🤖 Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Brian Krabach (bkrabach)
added a commit
that referenced
this pull request
Mar 7, 2026
…d backoff (#24) * feat: add retry_after and delay_multiplier to LLMError base class Add retry_after (float | None) and delay_multiplier (float) as keyword-only parameters on LLMError.__init__, with defaults None and 1.0 respectively. - Update LLMError docstring to document new attributes - Update LLMError.__repr__ to show retry_after when not None, delay_multiplier when != 1.0 - RateLimitError now forwards retry_after via super().__init__ instead of local assignment - ProviderUnavailableError accepts and forwards both new kwargs - All changes backward compatible (default values preserve existing behavior) - 12 new tests: TestRetryAfterOnBaseClass (7) + TestReprWithNewFields (5) 🤖 Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> * feat: generalize delay_multiplier and retry_after in retry_with_backoff - Apply error's delay_multiplier after max_delay cap (can exceed max_delay) - Generalize retry_after from RateLimitError-only to any LLMError - Update honor_retry_after docstring to reflect generic behavior - Order: base_delay → cap → multiplier → retry_after floor → jitter Tests: TestDelayMultiplier (3), TestRetryAfterOnAnyError (3) * fix: update stale RetryConfig class docstring to match generalized retry_after 🤖 Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> * style: move inline asyncio import to top-level in test_retry.py Moved import asyncio from inside the test_honor_retry_after_false_ignores_retry_after test body to the top-level imports for consistency with the rest of the file. Code quality review suggestion. 🤖 Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> * test: patch asyncio.sleep in slow retry_after test for faster CI Patched asyncio.sleep in test_retry_after_can_exceed_max_delay to avoid a ~5 second real sleep during CI. The test now uses unittest.mock.patch on asyncio.sleep (same pattern as the nearby test_retry_after_wins_over_multiplied_delay) while still asserting the correct delay value via the on_retry callback. Simplified the test to use AsyncMock side_effect instead of a manual closure. Tests now run in 2.5s instead of ~7.5s. 🤖 Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --------- Co-authored-by: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Ken Chau (kenotron-ms)
added a commit
that referenced
this pull request
May 8, 2026
…us.cost_usd — revives PR #73 Revives the approach from amplifier-core PR #73 (closed without merge 2026-05-07T18:15:32Z) with one critical refinement: use when_used='always' instead of default mode='json'-only. PROBLEM: M2 cost-tracking introduced cost_usd: Decimal | None on Usage and SessionStatus. Without serializer help, plain model_dump() (called by every orchestrator: loop-streaming line 472,505, loop-basic 361, loop-events 307,337) emits Decimal values into events, which crash json.dumps() at every downstream JSONL write site (hooks-logging, hooks-backup, hook-shell, context-persistent, foundation/serialization.py). A "patch every consumer" approach doesn't scale to N orchestrators or future hook modules. FIX: Move conversion to the model itself using @field_serializer("cost_usd", when_used="always"): - model_dump() and model_dump(mode='json') both return JSON-safe strings for cost_usd - Direct attribute access still returns Decimal for in-memory math - None survives as None (preserves None ≠ Decimal("0") invariant for "unknown" vs "free") - Plus _json_default(obj) in models.py + default= in ToolResult.get_serialized_output() for non-Pydantic paths where tool outputs may carry Decimal WHY "when_used='always'" MATTERS: PR #73 used default field_serializer behavior, which fires only on mode='json'. But every orchestrator calls plain model_dump(). Verification today (Pydantic 2.13.4) confirmed that without when_used='always', Decimal values still leak into events on the plain-dump path. VERIFIED BEHAVIORS (10 tests): ✓ model_dump() returns string for cost_usd ✓ json.dumps(usage.model_dump()) doesn't crash ✓ Direct usage.cost_usd access still returns Decimal ✓ Existing field_validator rejecting float still fires ✓ extra="allow" still works for provider-specific fields ✓ None survives as None (not "None") ✓ Round-trip model_validate(model_dump()) rebuilds with Decimal ✓ Precision preserved across 0.0000123, 12345.6789, 0, 1E-9 ✓ Plain model_dump() and mode='json' produce identical output ✓ int coerces to Decimal then serializes to string NEW TESTS: - tests/test_cost_usd_serialization.py: 25 tests (all pass) - 129 adjacent tests still pass (test_message_models, test_session, test_tool_result_autopop, test_interfaces, test_generated_equivalence) VERSION BUMP: 1.5.0 → 1.5.1 (PATCH, per release-mandate) - pyproject.toml - crates/amplifier-core/Cargo.toml - bindings/python/Cargo.toml - python/amplifier_core/__init__.py E2E SMOKE TEST: PASSED (run by user before push, per release-mandate Pre-Merge Gate) FILES CHANGED: - python/amplifier_core/message_models.py: +field_serializer on Usage.cost_usd - python/amplifier_core/models.py: +field_serializer on SessionStatus.cost_usd + _json_default + default= in ToolResult - tests/test_cost_usd_serialization.py: new, 234 lines - Version files: all bumped to 1.5.1 - Cargo.lock: regenerated by Rust toolchain UNBLOCKS M2/M3 milestone (microsoft-amplifier/amplifier-support#225): Once landed, these 11 PRs can drop per-consumer Decimal sanitizer code: - foundation #190: drop Decimal→str branch from serialization.py - hooks-logging #5: close as superseded (model_dump(mode='json') becomes redundant) - provider PRs: anthropic #52, openai #32, azure-openai #24, gemini #28, vllm #22, ollama #14, chat-completions #5 - consumer PRs: app-cli #171, hooks-streaming-ui #11 (unchanged) Plus 3 modules no longer need follow-up PRs: - amplifier-module-hooks-backup (automatic safety) - amplifier-module-hook-shell (automatic safety) - amplifier-module-context-persistent (automatic safety) Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Salil Das (sadlilas)
pushed a commit
that referenced
this pull request
May 8, 2026
…cost stamping (#73) * fix(core): @field_serializer(when_used='always') on Usage/SessionStatus.cost_usd — revives PR #73 Revives the approach from amplifier-core PR #73 (closed without merge 2026-05-07T18:15:32Z) with one critical refinement: use when_used='always' instead of default mode='json'-only. PROBLEM: M2 cost-tracking introduced cost_usd: Decimal | None on Usage and SessionStatus. Without serializer help, plain model_dump() (called by every orchestrator: loop-streaming line 472,505, loop-basic 361, loop-events 307,337) emits Decimal values into events, which crash json.dumps() at every downstream JSONL write site (hooks-logging, hooks-backup, hook-shell, context-persistent, foundation/serialization.py). A "patch every consumer" approach doesn't scale to N orchestrators or future hook modules. FIX: Move conversion to the model itself using @field_serializer("cost_usd", when_used="always"): - model_dump() and model_dump(mode='json') both return JSON-safe strings for cost_usd - Direct attribute access still returns Decimal for in-memory math - None survives as None (preserves None ≠ Decimal("0") invariant for "unknown" vs "free") - Plus _json_default(obj) in models.py + default= in ToolResult.get_serialized_output() for non-Pydantic paths where tool outputs may carry Decimal WHY "when_used='always'" MATTERS: PR #73 used default field_serializer behavior, which fires only on mode='json'. But every orchestrator calls plain model_dump(). Verification today (Pydantic 2.13.4) confirmed that without when_used='always', Decimal values still leak into events on the plain-dump path. VERIFIED BEHAVIORS (10 tests): ✓ model_dump() returns string for cost_usd ✓ json.dumps(usage.model_dump()) doesn't crash ✓ Direct usage.cost_usd access still returns Decimal ✓ Existing field_validator rejecting float still fires ✓ extra="allow" still works for provider-specific fields ✓ None survives as None (not "None") ✓ Round-trip model_validate(model_dump()) rebuilds with Decimal ✓ Precision preserved across 0.0000123, 12345.6789, 0, 1E-9 ✓ Plain model_dump() and mode='json' produce identical output ✓ int coerces to Decimal then serializes to string NEW TESTS: - tests/test_cost_usd_serialization.py: 25 tests (all pass) - 129 adjacent tests still pass (test_message_models, test_session, test_tool_result_autopop, test_interfaces, test_generated_equivalence) VERSION BUMP: 1.5.0 → 1.5.1 (PATCH, per release-mandate) - pyproject.toml - crates/amplifier-core/Cargo.toml - bindings/python/Cargo.toml - python/amplifier_core/__init__.py E2E SMOKE TEST: PASSED (run by user before push, per release-mandate Pre-Merge Gate) FILES CHANGED: - python/amplifier_core/message_models.py: +field_serializer on Usage.cost_usd - python/amplifier_core/models.py: +field_serializer on SessionStatus.cost_usd + _json_default + default= in ToolResult - tests/test_cost_usd_serialization.py: new, 234 lines - Version files: all bumped to 1.5.1 - Cargo.lock: regenerated by Rust toolchain UNBLOCKS M2/M3 milestone (microsoft-amplifier/amplifier-support#225): Once landed, these 11 PRs can drop per-consumer Decimal sanitizer code: - foundation #190: drop Decimal→str branch from serialization.py - hooks-logging #5: close as superseded (model_dump(mode='json') becomes redundant) - provider PRs: anthropic #52, openai #32, azure-openai #24, gemini #28, vllm #22, ollama #14, chat-completions #5 - consumer PRs: app-cli #171, hooks-streaming-ui #11 (unchanged) Plus 3 modules no longer need follow-up PRs: - amplifier-module-hooks-backup (automatic safety) - amplifier-module-hook-shell (automatic safety) - amplifier-module-context-persistent (automatic safety) Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> * test: update test_model_dump_includes_cost_usd_as_decimal for new contract The test was written for the OLD contract (model_dump() returns Decimal). With @field_serializer(when_used='always') on Usage.cost_usd, plain model_dump() now returns string — same as mode='json'. Renamed and updated assertion to match. Added a "direct attribute access still returns Decimal" assertion to make the bidirectional contract explicit. Self-correction: I missed sweeping bindings/python/tests/ in the original PR's verification pass; only ran tests/. The CI catch is exactly what the release-mandate Pre-Merge Gate is for. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> * test: drop bug-history references; lead docstrings with the contract Test docstrings shouldn't read like a changelog. A reader landing on these tests cares about what cost_usd actually is, not which milestone introduced it or which PR-number-of-the-week it was last fixed in. - Module docstring opens with "cost_usd is Decimal in memory ... JSON-safe string when serialized" and the None ≠ Decimal("0") invariant - Class docstrings: "Decimal in memory, string on the wire" - Per-test docstrings describe what the test verifies, not why history forced it (no @field_serializer, no _json_default, no PR #73, no M2) - Also: regenerate uv.lock for the 1.5.0 → 1.5.1 version bump that was missed when bumping the other version files manually 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --------- Co-authored-by: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Promotes
retry_afterfromRateLimitErrorto theLLMErrorbase class and adds a newdelay_multiplierattribute. This enables any error type to carry server-directed wait hints and error-severity backoff scaling.Motivation
Anthropic's
overloaded_error(HTTP 529) is a transient error where the server is too busy. Previously,retry_afterwas scoped toRateLimitErroronly, meaning overloaded errors couldn't carry server timing hints and couldn't signal that longer backoff is appropriate. This change makes both capabilities available to any error type.Changes
llm_errors.pyretry_after: float | None = NonetoLLMError.__init__— server-directed wait hintdelay_multiplier: float = 1.0toLLMError.__init__— error-severity scaling factorRateLimitErrornow forwardsretry_afterviasuper().__init__()instead of defining its ownProviderUnavailableErroraccepts both new kwargs__repr__includes both fields when non-defaultutils/retry.pydelay_multiplierapplied aftermax_delaycap (can exceed cap — intentional)isinstance(e, RateLimitError)guard removed —retry_afterhonored on anyLLMErrorhonor_retry_afterdocstring generalizedBackward Compatibility
Fully backward compatible:
retry_afterdefaults toNone— no behavior change unless explicitly setdelay_multiplierdefaults to1.0— no behavior change unless explicitly setRateLimitErrorconstruction withretry_after=Xstill works identicallyTesting
test_llm_errors.pyfor new attributes, repr, inheritancetest_retry.pyfor multiplier math, generalized retry_after, combined behavior