Skip to content

feat(llm-client): add bounded upstream retries - #173

Merged
nachiketb-nvidia merged 4 commits into
mainfrom
nachiketb/switch-1090-bounded-retries-normalization
Jul 29, 2026
Merged

feat(llm-client): add bounded upstream retries#173
nachiketb-nvidia merged 4 commits into
mainfrom
nachiketb/switch-1090-bounded-retries-normalization

Conversation

@nachiketb-nvidia

@nachiketb-nvidia nachiketb-nvidia commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

What

  • Add configurable bounded retries to switchyard-llm-client.
  • Expose max_retries in switchyard-server TOML with a default of 2 and a maximum of 10.
  • Retry buffered response-body transport failures while leaving streaming body failures outside the replay boundary.

Why

Transient transport failures, timeouts, HTTP 408/429 responses, and upstream 5xx responses should not immediately fail a request. The retry budget and delay policy must remain explicit and bounded because retries can replay billable requests and extend total latency.

How

  • Retry only transport failures, timeouts, HTTP 408/429, and 5xx responses.
  • Honor Retry-After seconds or HTTP dates with a 60-second cap; otherwise use bounded exponential backoff from 250 ms to 2 seconds.
  • Return the final typed upstream error unchanged after exhausting the retry budget.
  • Emit one libsy.upstream_attempt span per physical attempt with status, retry, and delay fields.
  • Collect buffered bodies inside the retry boundary; return successful streaming responses immediately and never replay stream-body failures.
  • Consolidate the repeated truncated-body cases into one table-driven test.

What to review

  • Retry classification and exhaustion behavior.
  • Buffered versus streaming replay boundaries.
  • Retry-After parsing and bounded backoff.
  • Attempt metrics and tracing.
  • Server retry defaults and maximum validation.

Validation

  • cargo fmt --all --check
  • cargo clippy -p switchyard-llm-client -p switchyard-server --all-targets -- -D warnings
  • cargo test -p switchyard-llm-client -p switchyard-server

@nachiketb-nvidia
nachiketb-nvidia requested a review from a team as a code owner July 28, 2026 22:51
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

LLM client retries and Anthropic normalization

Layer / File(s) Summary
Retry contract and configuration
Cargo.toml, crates/libsy-llm-client/..., crates/switchyard-server/..., crates/libsy/examples/ensemble.rs
Adds max_retries to backend and server configuration, exposes the default, propagates it into HTTP backends, and updates examples and documentation.
Retry execution and validation
crates/libsy-llm-client/src/client.rs
Adds retry classification, Retry-After and exponential backoff handling, instrumented attempts, and tests for recovery, exhaustion, timeout, and non-retryable failures.
Anthropic outbound normalization
crates/switchyard-translation/src/codecs/anthropic/buffered.rs, crates/switchyard-translation/tests/request_translation.rs
Normalizes preserved and encoded Anthropic requests, including system content, tool IDs, unsupported fields, and unsigned thinking blocks.
Anthropic backend routing
crates/switchyard-components/src/backends/anthropic.rs
Routes all Anthropic requests through the translation pipeline and removes duplicate local normalization logic.

Estimated code review effort: 4 (Complex) | ~45 minutes

Poem

A rabbit hops where retries bloom,
Backoff thumps through network gloom.
Anthropic’s messages shed their disguise,
Clean tools and thoughts now neatly arise.
Two little tries, then success may appear—
Squeak, ship, and celebrate here!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely captures the main change: bounded upstream retries in the LLM client.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/switchyard-server/src/config.rs (1)

202-242: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

No upper bound on max_retries lets a config value cause unbounded request stalls.

build_backend copies config.max_retries straight into HttpBackendConfig with no sanity cap, unlike the other fields validated in this function (base_url, api_key_env). Combined with the client's exponential backoff (up to 2s/attempt) and Retry-After cap (up to 60s/attempt), a large or accidental max_retries value can hold a request/task open for a very long time, degrading availability under load.

🛡️ Proposed bound check
+const MAX_ALLOWED_RETRIES: u32 = 10;
+
 fn build_backend(client_name: &str, config: &LlmClientConfig) -> ServerResult<Backend> {
     let base_url = config.base_url.trim();
     if base_url.is_empty() {
         return Err(ServerError::new(format!(
             "llm client {client_name} base_url must not be empty"
         )));
     }
+    if config.max_retries > MAX_ALLOWED_RETRIES {
+        return Err(ServerError::new(format!(
+            "llm client {client_name} max_retries must be at most {MAX_ALLOWED_RETRIES}"
+        )));
+    }
     let api_key = config
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/src/config.rs` around lines 202 - 242, Update
build_backend to validate config.max_retries before constructing
HttpBackendConfig, rejecting values above the established safe maximum with a
contextual ServerError. Preserve valid retry values and ensure the validated
value, rather than the unchecked configuration value, is assigned to
HttpBackendConfig.max_retries.
🧹 Nitpick comments (5)
crates/switchyard-translation/tests/request_translation.rs (1)

48-56: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for a message whose blocks are entirely stripped.

This fixture always leaves at least one signed/visible block, so the assistant-message-of-only-unsigned-thinking path (which currently produces empty content — see crates/switchyard-translation/src/codecs/anthropic/buffered.rs Lines 483-493) is untested.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-translation/tests/request_translation.rs` around lines 48 -
56, Add a test fixture in the request translation coverage where an assistant
message contains only unsigned thinking blocks, with no signed thinking or
visible text, and assert that translation produces empty content. Use the
existing request translation test structure and target the assistant-message
handling exercised by the buffered Anthropic codec.
crates/switchyard-translation/src/codecs/anthropic/buffered.rs (2)

402-402: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add brief intent comments to these helpers.

system_text_from_content_block and is_unsigned_thinking_block encode non-obvious contracts (which block types count as system text; what "unsigned" means). As per coding guidelines: "Use /// documentation comments for public Rust items and concise comments for module intent, public items, non-obvious helpers, important behavioral tests, and complex validation, routing, configuration, async, lifecycle, or concurrency logic."

Also applies to: 496-496

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-translation/src/codecs/anthropic/buffered.rs` at line 402,
||||Add concise intent comments immediately above the non-obvious helpers
system_text_from_content_block and is_unsigned_thinking_block, documenting which
content block types qualify as system text and what “unsigned” means for
thinking blocks. Use regular concise comments appropriate to these private Rust
helpers, without changing their behavior.

Source: Coding guidelines


387-400: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Non-text content is stringified into the system prompt.

The other => Some(other.to_string()) arm serializes numbers/objects as raw JSON into system, and the array arm silently drops non-text blocks (e.g. images). Returning None for non-textual content — with a lossy diagnostic where the codec already pushes them — would be more predictable than leaking JSON literals into the prompt.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-translation/src/codecs/anthropic/buffered.rs` around lines
387 - 400, The system_text_from_content function should return None for
non-textual scalar/object values instead of serializing them with
other.to_string(). Update the array handling so non-text blocks are not silently
converted or leaked into the system prompt, while preserving text extraction and
joining; emit the codec’s existing lossy diagnostic at the point where discarded
content is reported.
crates/libsy-llm-client/src/client.rs (2)

474-513: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add brief comments for the retry-classification/backoff internals.

AttemptFailure (what it captures and why) and retry_delay (exponential backoff shape) have no explanatory comment, unlike retry_after_delay right above them. This is non-obvious async/retry logic that future maintainers will need to reason about.

As per coding guidelines, "Use /// documentation comments for public Rust items and concise comments for module intent, public items, non-obvious helpers, ... complex ... async, lifecycle, or concurrency logic."

📝 Proposed comments
+// Failure metadata for one upstream attempt: whether it's worth retrying and,
+// if so, how long to wait first.
 struct AttemptFailure {
     error: LlmClientError,
     status: Option<u16>,
     retry_after: Option<Duration>,
 }
+// Prefers the upstream's `Retry-After` hint; otherwise doubles the delay each
+// attempt starting from `INITIAL_RETRY_DELAY`, capped at `MAX_RETRY_BACKOFF`.
 fn retry_delay(retry_number: u32, retry_after: Option<Duration>) -> Duration {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/libsy-llm-client/src/client.rs` around lines 474 - 513, Add concise
explanatory comments for the non-obvious retry internals: document
AttemptFailure’s captured error/status/retry metadata and its role in retry
classification, and document retry_delay’s exponential backoff behavior,
including its bounded growth and Retry-After override. Keep the existing retry
logic unchanged and use comments appropriate for these private helpers.

Source: Coding guidelines


173-219: 🩺 Stability & Availability | 🔵 Trivial

Retries can duplicate non-idempotent, billable upstream calls; no overall deadline bounds the loop.

Two related considerations worth weighing, not blockers:

  • A Timeout/Transport failure is always retried with the identical request body. If the failure is a response-read timeout that occurs after the upstream already processed the (billable) completion, the retry issues a second identical call — most chat/completions APIs don't offer an idempotency key to dedupe this.
  • The loop has no overall wall-clock deadline of its own; with a large Retry-After (capped at 60s) and max_retries attempts, one logical call can legitimately take several minutes, bounded only by whatever timeout the caller configures on reqwest::Client per attempt.

Worth documenting as an accepted tradeoff, or wrapping the whole retry loop in an overall timeout (e.g., tokio::time::timeout) if callers need a hard latency ceiling.

Also applies to: 480-489

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/libsy-llm-client/src/client.rs` around lines 173 - 219, Document the
retry behavior around the upstream retry loop in the relevant client method:
retries of Timeout/Transport failures may duplicate already-processed, billable
non-idempotent requests, and Retry-After delays plus max_retries can extend
total latency without an overall deadline. If the client contract requires a
hard wall-clock bound, wrap the entire retry loop in an overall timeout;
otherwise explicitly document this accepted tradeoff, preserving the existing
per-attempt retry behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/switchyard-translation/src/codecs/anthropic/buffered.rs`:
- Around line 483-493: The strip_unsigned_thinking_from_message flow must not
return a message with empty-string content when all blocks are removed; drop
that message or emit a non-empty placeholder while preserving normal content
handling. In crates/switchyard-translation/src/codecs/anthropic/buffered.rs
lines 483-493, update the kept.is_empty() branch accordingly. In
crates/switchyard-translation/tests/request_translation.rs lines 48-56, add an
assistant message containing only unsigned thinking blocks and assert the
resulting message shape.

---

Outside diff comments:
In `@crates/switchyard-server/src/config.rs`:
- Around line 202-242: Update build_backend to validate config.max_retries
before constructing HttpBackendConfig, rejecting values above the established
safe maximum with a contextual ServerError. Preserve valid retry values and
ensure the validated value, rather than the unchecked configuration value, is
assigned to HttpBackendConfig.max_retries.

---

Nitpick comments:
In `@crates/libsy-llm-client/src/client.rs`:
- Around line 474-513: Add concise explanatory comments for the non-obvious
retry internals: document AttemptFailure’s captured error/status/retry metadata
and its role in retry classification, and document retry_delay’s exponential
backoff behavior, including its bounded growth and Retry-After override. Keep
the existing retry logic unchanged and use comments appropriate for these
private helpers.
- Around line 173-219: Document the retry behavior around the upstream retry
loop in the relevant client method: retries of Timeout/Transport failures may
duplicate already-processed, billable non-idempotent requests, and Retry-After
delays plus max_retries can extend total latency without an overall deadline. If
the client contract requires a hard wall-clock bound, wrap the entire retry loop
in an overall timeout; otherwise explicitly document this accepted tradeoff,
preserving the existing per-attempt retry behavior.

In `@crates/switchyard-translation/src/codecs/anthropic/buffered.rs`:
- Line 402: ||||Add concise intent comments immediately above the non-obvious
helpers system_text_from_content_block and is_unsigned_thinking_block,
documenting which content block types qualify as system text and what “unsigned”
means for thinking blocks. Use regular concise comments appropriate to these
private Rust helpers, without changing their behavior.
- Around line 387-400: The system_text_from_content function should return None
for non-textual scalar/object values instead of serializing them with
other.to_string(). Update the array handling so non-text blocks are not silently
converted or leaked into the system prompt, while preserving text extraction and
joining; emit the codec’s existing lossy diagnostic at the point where discarded
content is reported.

In `@crates/switchyard-translation/tests/request_translation.rs`:
- Around line 48-56: Add a test fixture in the request translation coverage
where an assistant message contains only unsigned thinking blocks, with no
signed thinking or visible text, and assert that translation produces empty
content. Use the existing request translation test structure and target the
assistant-message handling exercised by the buffered Anthropic codec.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 3c1c5a69-d06d-4701-8e45-79743e36b3de

📥 Commits

Reviewing files that changed from the base of the PR and between 61f41ff and e766ea9.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !Cargo.lock
📒 Files selected for processing (14)
  • Cargo.toml
  • crates/libsy-llm-client/Cargo.toml
  • crates/libsy-llm-client/README.md
  • crates/libsy-llm-client/src/backend.rs
  • crates/libsy-llm-client/src/client.rs
  • crates/libsy-llm-client/src/lib.rs
  • crates/libsy/examples/ensemble.rs
  • crates/switchyard-components/src/backends/anthropic.rs
  • crates/switchyard-server/CONFIGURATION.md
  • crates/switchyard-server/README.md
  • crates/switchyard-server/src/config.rs
  • crates/switchyard-server/tests/server.rs
  • crates/switchyard-translation/src/codecs/anthropic/buffered.rs
  • crates/switchyard-translation/tests/request_translation.rs

Comment thread crates/switchyard-translation/src/codecs/anthropic/buffered.rs Outdated
@nachiketb-nvidia nachiketb-nvidia changed the title feat(llm-client): add bounded upstream retries feat(llm-client): add bounded retries and Anthropic normalization Jul 28, 2026
@nachiketb-nvidia
nachiketb-nvidia force-pushed the nachiketb/switch-1090-bounded-retries-normalization branch from 158747c to 16802c0 Compare July 29, 2026 16:51
Comment thread crates/libsy-llm-client/src/client.rs Outdated
Comment thread crates/libsy-llm-client/src/client.rs
@nachiketb-nvidia
nachiketb-nvidia force-pushed the nachiketb/switch-1090-bounded-retries-normalization branch from b5ab48f to 6a83860 Compare July 29, 2026 17:54
@nachiketb-nvidia nachiketb-nvidia changed the title feat(llm-client): add bounded retries and Anthropic normalization feat(llm-client): add bounded upstream retries Jul 29, 2026
@grahamking

grahamking commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

I got this from Codex, looks legit. Could you check it?

Retry metrics misclassify failures. crates/libsy-llm-client/src/client.rs:241 records an upstream attempt immediately after receiving headers. If a buffered 200 body is truncated, the new logic retries it, but the metric has already counted that failed attempt as success.

Additionally, crates/libsy-llm-client/src/metrics.rs:8 labels only 429/500/504 as retryable, while this PR retries 408 and every 5xx. This will understate failures in the direct-endpoint error-rate dashboards added on main. Record the metric after buffered-body collection and align its outcome classification with the retry policy.

This is likely because of the metrics stuff I just landed. Might need a rebase to see the issue.

Signed-off-by: nachiketb <nachiketb@nvidia.com>
Signed-off-by: nachiketb <nachiketb@nvidia.com>
Signed-off-by: nachiketb <nachiketb@nvidia.com>
Signed-off-by: nachiketb <nachiketb@nvidia.com>
@nachiketb-nvidia
nachiketb-nvidia force-pushed the nachiketb/switch-1090-bounded-retries-normalization branch from 6a83860 to 503ed36 Compare July 29, 2026 20:40
@nachiketb-nvidia

Copy link
Copy Markdown
Contributor Author

I got this from Codex, looks legit. Could you check it?

Retry metrics misclassify failures. crates/libsy-llm-client/src/client.rs:241 records an upstream attempt immediately after receiving headers. If a buffered 200 body is truncated, the new logic retries it, but the metric has already counted that failed attempt as success.
Additionally, crates/libsy-llm-client/src/metrics.rs:8 labels only 429/500/504 as retryable, while this PR retries 408 and every 5xx. This will understate failures in the direct-endpoint error-rate dashboards added on main. Record the metric after buffered-body collection and align its outcome classification with the retry policy.

This is likely because of the metrics stuff I just landed. Might need a rebase to see the issue.

changed which codes are retryable!

@nachiketb-nvidia
nachiketb-nvidia merged commit ef646cd into main Jul 29, 2026
15 checks passed
@nachiketb-nvidia
nachiketb-nvidia deleted the nachiketb/switch-1090-bounded-retries-normalization branch July 29, 2026 21:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants