Skip to content

Retry BMC reads once after refreshing credentials on 401 - #4216

Open
wminckler wants to merge 3 commits into
NVIDIA:mainfrom
wminckler:fix/bmc-retry-after-auth-refresh
Open

Retry BMC reads once after refreshing credentials on 401#4216
wminckler wants to merge 3 commits into
NVIDIA:mainfrom
wminckler:fix/bmc-retry-after-auth-refresh

Conversation

@wminckler

@wminckler wminckler commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

BMC credentials rotate underneath the long-lived BmcClient that the hardware-health collectors share. When a request lands on the wrong side of a rotation, the BMC answers 401. BmcClient already noticed this and refreshed its credentials — but it then returned the original failure to the caller without replaying the request. Every rotation therefore burned one request per resource that happened to be in flight.

For the metric collectors that failure is not benign. Each collection interval is bracketed by MetricCollectionStart/MetricCollectionEnd, and a fetch that fails emits no samples, so the sink sweeps that resource's series for the interval. On the next interval the refreshed credentials work and the series come back. The observable symptom is stable hw_metric series churning between consecutive intervals — series counts moving 152 → 142 → 142 and 126 → 142 with different identity hashes across a short window, alongside a large fetch-failure count — which stops the collector from reliably publishing the complete default metric catalog.

This replaces refresh_auth_if_needed with read_with_auth_retry, which takes the operation as a re-runnable closure and, after a successful refresh, replays it once.

A few deliberate constraints:

  • Reads only. Only the idempotent operations (get, expand, filter, stream) route through the retry. Mutating calls (create, update, delete, action, multipart_update, create_session) are unchanged and still fail fast, so there is no risk of a replayed write.
  • Single-shot. The retry runs with credentials strictly newer than the ones that were rejected, so a second 401 means the credentials are wrong rather than stale. Retrying further would only multiply load against a BMC that is already refusing us.
  • A no-op refresh still retries. refresh_credentials returns early when a concurrent caller has already rotated past the observed generation. That still means the credentials now in place are newer than the rejected ones, so the replay is warranted — and no extra provider fetch happens.
  • Refresh failure surfaces the original error, not the refresh error, preserving the existing auth signal for callers.

Circuit-breaker semantics are unchanged: both attempts run through guarded, and an open-circuit fast-fail is not an auth error, so it never triggers a refresh.

Related issues

Type of Change

  • Add - New feature or capability
  • Change - Changes in existing functionality
  • Fix - Bug fixes
  • Remove - Removed features or deprecated functionality
  • Internal - Internal changes (refactoring, tests, docs, etc.)

Breaking Changes

  • This PR contains breaking changes

Testing

  • Unit tests added/updated
  • Integration tests added/updated
  • Manual testing performed
  • No testing required (docs, internal refactor, etc.)

Five new cases in bmc::tests drive read_with_auth_retry with a scripted operation that records attempt counts:

  • read_retries_once_after_refreshing_on_auth_error — the regression itself: 401 → refresh → retry succeeds, in exactly 2 attempts and 2 provider calls.
  • read_does_not_retry_non_auth_errors — a 404 surfaces immediately, with no credential refresh.
  • read_retries_at_most_once — a persistent 401 stops at 2 attempts and 1 refresh.
  • read_surfaces_original_error_when_refresh_fails — no retry, and the original 401 (not the refresh error) surfaces.
  • read_retries_when_a_concurrent_caller_already_refreshed — the no-op refresh path still retries, without re-fetching credentials.

Full carbide-health lib suite passes (398 tests), plus clippy and rustfmt.

Additional Notes

This implements the retry half of the fix, not the "preserve last known series" half. If a BMC resource is genuinely unreadable for an entire interval — a real outage rather than a credential rotation — its series will still be swept. Whether the sink should hold last-known series across a failed interval is a separate design question about staleness handling, and is intentionally out of scope here.

expand and filter now clone their ExpandQuery/FilterQuery per attempt, since those are consumed by value and the closure has to be callable twice.

🤖 Generated with Claude Code

BmcClient refreshed credentials after an authentication error but returned
the original failure without replaying the request. Since BMC credentials
rotate underneath a long-lived client, every rotation burned one request per
in-flight resource.

For the metric collectors that failure is not benign: a fetch that fails
emits no samples for the interval, so the sink sweeps that resource's series
and they reappear on the next interval. The result is stable hw_metric series
churning between collection intervals and an incomplete metric catalog.

Replace refresh_auth_if_needed with read_with_auth_retry, which takes the
operation as a re-runnable closure and replays it once after a successful
refresh. Only the idempotent reads (get/expand/filter/stream) use it, so
mutating calls still fail fast. The retry is single-shot: it runs with
credentials newer than the rejected ones, so a second 401 means the
credentials are wrong rather than stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wminckler
wminckler requested a review from a team as a code owner July 27, 2026 20:29
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 73a150c9-b645-4598-8276-569194371ff1

📥 Commits

Reviewing files that changed from the base of the PR and between 8a016e0 and b79ab43.

📒 Files selected for processing (1)
  • crates/health/src/bmc.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/health/src/bmc.rs

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of authentication failures during read operations, including a controlled single retry after credential refresh.
    • Added consistent auth-retry behavior across read and selective stream establishment, without changing long-lived streaming behavior.
    • Avoids retries for non-authentication errors and prevents additional retries on consecutive auth failures.
    • Preserves the original authentication error when credential refresh fails.
    • Improves reliability with concurrent credential generation updates and ensures correct replay behavior for public read requests.

Walkthrough

BMC read operations now share centralized credential validation, authentication-error refresh, and one-time retry behavior. Stream establishment uses the same path while item-level errors remain unchanged. Tests cover retry limits, refresh failures, and credential-generation races.

Changes

BMC authentication retry

Layer / File(s) Summary
Centralize read authentication retry
crates/health/src/bmc.rs
read_with_auth_retry coordinates credential checks, circuit-breaker execution, generation-aware refresh, and exactly one retry. Refresh logging now uses debug level.
Route reads and stream establishment
crates/health/src/bmc.rs
expand, get, filter, and stream establishment use the shared helper; stream item errors are not retried through this mechanism.
Validate retry semantics
crates/health/src/bmc.rs
Tests cover authentication and non-authentication errors, refresh failures, repeated failures, rotated credentials, concurrent credential-generation updates, HTTP replay for get, and no replay for delete.

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

Sequence Diagram(s)

sequenceDiagram
  participant BmcClient
  participant read_with_auth_retry
  participant Credentials
  participant CircuitBreaker
  BmcClient->>read_with_auth_retry: establish read or stream
  read_with_auth_retry->>Credentials: ensure credentials and capture generation
  read_with_auth_retry->>CircuitBreaker: execute guarded operation
  CircuitBreaker-->>read_with_auth_retry: authentication error
  read_with_auth_retry->>Credentials: refresh when generation is unchanged
  read_with_auth_retry->>CircuitBreaker: retry operation once
  CircuitBreaker-->>BmcClient: return result
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main change: BMC reads now retry once after a credential refresh on 401.
Description check ✅ Passed The description accurately explains the BMC 401 refresh-and-retry fix and its read-only scope.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
crates/health/src/bmc.rs (1)

239-292: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Solid retry/generation logic — consider logging the exhausted-retry path too.

Traced this against all five new tests (read_retries_once_after_refreshing_on_auth_error, read_does_not_retry_non_auth_errors, read_retries_at_most_once, read_surfaces_original_error_when_refresh_fails, read_retries_when_a_concurrent_caller_already_refreshed) — the generation-gated refresh, single-shot retry, and "no-op refresh still counts as success" semantics all check out correctly.

One asymmetry: refresh failure (line 276) and retry success (line 286) are both logged, but a second auth failure on the retried guarded(op()) call (line 285) — which per your own doc comment "means the credentials themselves are wrong, not stale" — passes through silently. That's exactly the signal an operator would want when debugging a BMC that keeps rejecting rotated credentials.

♻️ Proposed addition to log the exhausted-retry case
-        self.guarded(op()).await.inspect(|_| {
-            tracing::debug!(
-                original_error = ?error,
-                endpoint = ?self.addr,
-                "Retry after BMC credential refresh succeeded"
-            );
-        })
+        self.guarded(op()).await.inspect(|_| {
+            tracing::debug!(
+                original_error = ?error,
+                endpoint = ?self.addr,
+                "Retry after BMC credential refresh succeeded"
+            );
+        }).inspect_err(|retry_error| {
+            tracing::warn!(
+                error = ?retry_error,
+                original_error = ?error,
+                endpoint = ?self.addr,
+                "Retry after BMC credential refresh also failed"
+            );
+        })
🤖 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/health/src/bmc.rs` around lines 239 - 292, Update read_with_auth_retry
so the second guarded(op()) attempt logs an error when it fails, including the
retry error, original authentication error, and endpoint context; preserve the
existing success debug log and return the retry result unchanged.
🤖 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.

Nitpick comments:
In `@crates/health/src/bmc.rs`:
- Around line 239-292: Update read_with_auth_retry so the second guarded(op())
attempt logs an error when it fails, including the retry error, original
authentication error, and endpoint context; preserve the existing success debug
log and return the retry result unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 40b67280-32e5-4bfa-be16-cf5246dcf95f

📥 Commits

Reviewing files that changed from the base of the PR and between 696b53e and de4adb8.

📒 Files selected for processing (1)
  • crates/health/src/bmc.rs

wminckler and others added 2 commits July 27, 2026 17:04
Address review on the retry change: a retry that fails after the refresh
returned silently, which is the one outcome an operator needs. It now warns,
carrying both the retry error and the original one.

Also drop the pre-existing refresh warning to debug. Its only production
caller is now the retry path, so it fires on a first auth failure that the
very next line recovers from — an expected consequence of credential
rotation, not an operator signal. Warning there while the genuine failure
stayed silent had the levels exactly backwards, and since auth errors do not
trip the circuit breaker it also meant a misconfigured endpoint warned once
per resource per interval with no backoff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The five existing tests drive `read_with_auth_retry` directly, so nothing
pinned the four call sites to it: reverting `get` to its old non-retrying
body left every one of them passing. Verified by doing exactly that — only
the new test caught it.

Serve a scripted 401-then-200 from a local listener and point a real
BmcClient at it through `proxy_url`, which `bmc_url` returns verbatim, so the
client speaks plain HTTP and no TLS setup is needed. Counting connections
(each response closes) makes "the request was replayed on the wire" a direct
assertion rather than an inference from the credential provider call count.

The companion test pins the read-only boundary: a mutating call must not be
replayed, which is a safety property rather than just a comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wminckler

Copy link
Copy Markdown
Contributor Author

Two notes for reviewers on things that aren't obvious from the diff.

Log levels moved. "Authentication failed, refreshing BMC credentials" dropped from warn to debug, and a new warn was added for a retry that fails after a successful refresh. The rationale: once the caller replays the request, a first 401 is an expected consequence of credential rotation that the very next line recovers from — warning on a successfully handled event. The genuinely actionable case is freshly fetched credentials being refused, which previously returned silently. Resulting arrangement:

Event Level
First attempt 401, about to refresh + retry debug
Refresh itself failed (could not obtain new credentials) error
Retry succeeded after refresh debug
Retry failed after a successful refresh warn

Nothing in this repo greps for the downgraded message, but anything external keying on it at default level will go quiet.

Known limitation — auth failures still have no backoff. is_connection_error is false for 401/403, so guarded treats an auth failure as "the BMC answered" and the circuit breaker never opens on it. On a genuinely misconfigured endpoint this change takes reads from 1 to 2 HTTP requests per resource per interval, and (pre-existing) each request still drives a full credential-provider fetch, since sequential callers each observe a fresh generation. The existing breaker is deliberately not reused here: it is a connection circuit, and tripping it on 401 would make collectors report an authorization problem as unreachability and skip the endpoint wholesale. Damping this properly wants either an auth-specific breaker state or a per-endpoint refresh cooldown — worth a follow-up issue rather than widening this PR.

@wminckler

Copy link
Copy Markdown
Contributor Author

Two more review points, both considered and deliberately not changed.

The per-attempt query.clone() in expand/filter stays. Because the closure has to be callable twice, these now clone their query on every call, including the common path where no retry happens. A Cow does not help: inner.expand(id, query) takes the query by value, so a Cow would still need .into_owned() per attempt, which clones in the borrowed case anyway. Avoiding the clone on the success path would mean giving the helper a separate retry closure so the first attempt can consume the original — more machinery than the cost justifies. That cost is one small String allocation: ExpandQuery is { expand_expression: String, levels: Option<u32> }, and the expression is typically "." or "*". Against a network round-trip it is not measurable.

No metric was added for retries. A counter distinguishing "read succeeded on the replay" from "read succeeded outright" would make this fix observable in the field, and today a successful retry is indistinguishable from a clean read. It is not a small addition, for three reasons:

  • BmcClient has no metrics handle. Collector counters are constructed in collectors/runtime.rs from a registry with const labels; giving the client one means changing BmcClient::new and every call site.
  • A BmcClient is shared by all collectors on an endpoint, so a client-side counter read from each collector's loop double-counts. Correct attribution needs endpoint-level registration, which does not exist today.
  • A new metric also needs a docs/observability/core_metrics.md entry to satisfy the metric-docs lint.

Both are recorded here so the reasoning is visible rather than re-derived at the next review.

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