Skip to content

feat(llm-client): add bounded retries and Anthropic normalization - #173

Open
nachiketb-nvidia wants to merge 4 commits into
mainfrom
nachiketb/switch-1090-bounded-retries-normalization
Open

feat(llm-client): add bounded retries and Anthropic normalization#173
nachiketb-nvidia wants to merge 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.
  • Move conservative Anthropic outbound request normalization into switchyard-translation and remove the duplicate backend implementation.
  • Expose max_retries in switchyard-server TOML with a default of 2.
  • Restore target-level extra_body support in the libsy client/server path.
  • Request OpenAI Chat streaming usage by default while preserving an explicit caller opt-out.

Why

Transient transport failures, timeouts, HTTP 408/429 responses, and upstream 5xx responses should not immediately fail a request. Anthropic wire normalization belongs in the translation layer so every caller gets the same provider-compatible shape.

The Rust server path also needs the existing per-target request defaults used for provider options such as service_tier and chat_template_kwargs. OpenAI Chat streams must request usage events for complete token accounting.

How

  • Retry only transport failures, timeouts, HTTP 408/429, and 5xx responses.
  • Collect buffered response bodies inside the retry boundary, including failures while reading successful or error responses.
  • Honor Retry-After seconds or HTTP dates with a 60-second cap; otherwise use bounded exponential backoff from 250 ms to 2 seconds.
  • Stop after the configured retry budget and return the final typed upstream error unchanged.
  • Emit one libsy.upstream_attempt span per attempt with status, retry, and delay fields; retry sleep is outside the attempt span.
  • Retry streaming calls only until a successful response is returned; stream-body failures are not replayed.
  • Normalize exact-preserved and translated Anthropic requests in the Anthropic codec, retaining other provider extensions while fixing incompatible fields, system turns, tool IDs, and unsigned thinking blocks.
  • Shallow-merge target extra_body fields after translation without overriding fields explicitly supplied by the request.
  • Set stream_options.include_usage = true on OpenAI Chat streaming requests when the caller did not provide a value.

What to review

  • Retry classification, buffered-body boundaries, and exhaustion behavior in libsy-llm-client.
  • The streaming replay boundary and attempt telemetry.
  • OpenAI Chat usage opt-in and explicit opt-out behavior.
  • Target-level extra_body parsing and request-wins merge semantics.
  • Anthropic same-format normalization and removal of backend-owned duplicate logic.

Validation

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace

Signed-off-by: nachiketb <nachiketb@nvidia.com>
Signed-off-by: nachiketb <nachiketb@nvidia.com>
@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 matches the main changes: bounded retries in the LLM client and Anthropic normalization work.

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 on lines +483 to +493
let kept = blocks
.into_iter()
.filter(|block| !is_unsigned_thinking_block(block))
.collect::<Vec<_>>();
let content = if kept.is_empty() {
Value::String(String::new())
} else {
Value::Array(kept)
};
message.insert("content".to_string(), content);
Value::Object(message)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Messages emptied by thinking-block stripping produce empty content. Root cause: strip_unsigned_thinking_from_message falls back to Value::String(String::new()) when every block is filtered out, which Anthropic rejects; the new test never exercises that path so the defect is invisible.

  • crates/switchyard-translation/src/codecs/anthropic/buffered.rs#L483-L493: drop the message (or emit a non-empty placeholder) instead of empty-string content.
  • crates/switchyard-translation/tests/request_translation.rs#L48-L56: add an assistant message containing only unsigned thinking blocks and assert the resulting message shape.
📍 Affects 2 files
  • crates/switchyard-translation/src/codecs/anthropic/buffered.rs#L483-L493 (this comment)
  • crates/switchyard-translation/tests/request_translation.rs#L48-L56
🤖 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
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.

Signed-off-by: nachiketb <nachiketb@nvidia.com>
@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
Signed-off-by: nachiketb <nachiketb@nvidia.com>
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.

1 participant