Skip to content

fix(protocol)!: answer a pinned request from its node or fail, with lava-pin-allow-retry to opt out (MAG-2280, MAG-4123) - #578

Draft
Tomelia1999 wants to merge 2 commits into
mainfrom
fix/pinned-requests-never-reroute
Draft

Tomelia1999 wants to merge 2 commits into
mainfrom
fix/pinned-requests-never-reroute

Conversation

@Tomelia1999

@Tomelia1999 Tomelia1999 commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Blocked: do not merge until QA agrees. This PR changes how pinned requests behave, and about 90 automation tests depend on the old behaviour. It waits until QA (Victoria) confirms the test changes in MAG-4674 are in: the pin helpers send lava-pin-allow-retry: true, and the pin-contract tests are ready to flip.

#Closes MAG-2280
#Closes MAG-4123

Jira ticket: MAG-2280

Summary

The rule. A request pinned with lava-select-provider or lava-stickiness is answered by the pinned upstream or fails with that upstream's own error. The router never quietly answers it from another node. Same rule for both headers.

Three paths broke it, all closed here:

  • MAG-2280, retry after a retryable error. The pinned node answered 429; the retry batch dropped the pin (MAG-2228) and another node served. For the head-then-fetch sequence lava-stickiness exists for, the fallback node was never checked to have reached the block, so this produced the very false gap the header was bought to prevent; the pin also stayed on the failing node for the next request. Now the policy stops with reason Pinned and the caller gets the node's error, Lava-Retries: 0.
  • MAG-4123, the router's own consistency gate. A pinned latest call whose node lagged more than the threshold was released and resent without the pin: another node answered with Lava-Retries: 0, invisible. Now the gate fails the request with pinned provider is too far behind the chain tip; a pinned request is not served by another provider (lag and threshold logged). Option A from the ticket. Concrete-block requests are unchanged.
  • The hedge. The attempt window hedged a slow pinned request onto another node. Now a pinned request waits for its node up to the attempt budget.

Opt-out per request: lava-pin-allow-retry: true (second commit). Sent with either pin header, it keeps the pin to the first attempt only: retries, hedges and the consistency gate's retry may reach other providers and are counted in Lava-Retries; the session's pin is never moved. Only true (any case) enables it; alone it does nothing. For customers who prefer a node but would rather get an answer elsewhere than an error, and for the QA suite, which can add this one header to its pin helpers instead of rewriting ~90 tests that use the pin to steer the first attempt.

Where it lives.

  • relaycore: DecisionInput.Pinned (from the request's original directive headers) and two helpers, PinnedByDirectives and PinnedUpstreamUnservable; the send-stop arm records PinnedProviderUnavailable.
  • relaypolicy: Decide stops a pinned request right after the cross-validation check, so before the stateful rate-limit carve-out (a pinned write refused for rate is not broadcast elsewhere either); OnSendRelayResult stops at once on a selection error that names the pin instead of after SendRelayAttempts identical resends.
  • rpcsmartrouter: resolvePinDirectives honours the headers on every attempt (the firstAttempt parameter is gone); sendRelayToDirectEndpoints returns pinnedConsistencyRejection instead of the retry-triggering gate error when the request is pinned and not cross-validated.
  • lavasession: selection receives triedByThisRequest; a sticky provider that already failed this request is refused with SelectedProviderAlreadyFailedError, like a named pin, instead of falling through to another provider. The busy-refill fall-through is untouched.

Not in this PR, on purpose: a bounded wait-and-retry on the same node for a 429 with a short Retry-After; and whether a pod-local sticky session whose node is merely busy (out of CU, session cap) should fail rather than re-pin. Both are separate decisions.

Known, handled in MAG-4481: a pinned node's 429 reaches the client as HTTP 503 with the router's text, because the router stamps "all rate-limited → 503" (RelayProcessor.buildFailureResult). That is MAG-4481 decision B (return the node's 429 as is), not a pin issue. The fix lands with MAG-4481, after which a pinned 429 goes back to the caller as the node's own 429.

Docs: Magma-Devs/docs#77 (draft until this is in a release) rewrites the three sentences that promised the old failover.

QA follow-ups (automation repo): the strict xfail on test_stickiness_preserved_across_retries_when_pinned_provider_returns_retryable_error asserts a contract nobody designed; the expected history is now [pinned=error] with Lava-Retries: 0 and the node's error returned. The observation test "retry of a sticky request leaves the shared claim unchanged" (P then Q, Lava-Retries ≥ 1) flips to the new contract. test_pinned_call_stays_on_a_lagging_pinned_provider.py (expected failure today) should pass.

Verification

  • go build ./..., go vet ./... clean, gofmt -l clean.
  • New tests: policy_pinned_test.go (pinned never retries, hedges or broadcasts; pin errors stop the send stage at once), relaycore/pinned_test.go, rpcsmartrouter/pinned_request_test.go (real state machine over the REST mock chain: a node error ends on Pinned after exactly one batch; a pin refusal ends on the first send with PinnedProviderUnavailable; the gate's error is terminal and names the lag), lavasession/sticky_pinned_retry_test.go.
  • Updated: TestResolvePinDirectives (pins read on every attempt), the two relay_end goldens for an unknown pinned provider (stop_reason is now PinnedProviderUnavailable), the hang-marks list of non-blaming stop reasons, and TestRESTListener_HedgedLoserCountsAsARetry, which steered its first attempt with a pin and now uses a fixed selection order.
  • Full suites of relaypolicy, relaycore, lavasession and rpcsmartrouter pass.

Author Checklist

I have...

  • read the contribution guide
  • included the correct type prefix in the PR title
  • confirmed ! in the type prefix if API or client breaking change (a pinned request that used to be served by another node now returns an error)
  • targeted the main branch
  • provided a link to the relevant issue or specification (MAG-2280, MAG-4123)
  • reviewed "Files changed" and left comments if necessary
  • included the necessary unit and integration tests
  • updated the relevant documentation or specification, including comments for documenting Go code (Go comments here; product docs in docs(stickiness): a pinned request fails instead of failing over (MAG-2280, MAG-4123) docs#77; internal stickiness guide updated)
  • confirmed all CI checks have passed

Reviewers Checklist

I have...

  • confirmed the correct type prefix in the PR title
  • confirmed all author checklist items have been addressed
  • reviewed state machine logic, API design and naming, documentation is accurate, tests and test coverage

🤖 Generated with Claude Code

// to the caller with Lava-Retries 0. Checked before the stateful rate-limit carve-out, so a
// pinned write refused for rate is not broadcast elsewhere either. Cross-validation overrides
// pins and stopped above.
if input.Pinned {

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.

@Tomelia1999 A question from QA automation, to resolve before this merges.

This line makes a pinned request (lava-select-provider) stop after the pinned provider: it is answered by that provider, or it fails with that provider's own error. It is never retried on another provider.

Our failover tests use the pin to make the first attempt go to a chosen provider, and then check that the retry goes to another provider. A text search of the automation repository (Magma-Devs/smart-router-automation) finds 21 failover test files that pin a request. 14 of them also assert a retry or a second provider in the reply (counted by the search, not read one by one). Examples:

  • test_a_jsonrpc_router_fails_over_at_once_when_a_provider_port_is_closed.py
  • test_rest_and_grpc_requests_move_to_another_primary_when_the_pinned_primary_goes_quiet.py
  • test_the_last_chance_with_a_backup_tier.py
  • test_a_retry_goes_to_another_primary_and_not_to_a_backup_when_only_the_backups_offer_archive.py (merged on 2026-10-26)

From the description of this PR, these tests will fail once it merges. The failover regression runs on router main at 03:00 UTC from Sunday to Thursday (its image variable FAILOVER_REGRESSION_IMAGE is not set), so the next run after the merge goes red.

Question: after this change, what is the supported way for a test to make the first attempt of a request go to a chosen provider, and still let the retry go to another provider? For example an override that holds for the first attempt only, or a debug setting.

QA needs your answer to change the tests in time. Please answer here and resolve this thread before you merge.

@Tomelia1999 Tomelia1999 changed the title fix(protocol)!: answer a pinned request from its node or fail, never from another node (MAG-2280, MAG-4123) fix(protocol)!: answer a pinned request from its node or fail, with lava-pin-allow-retry to opt out (MAG-2280, MAG-4123) Oct 26, 2026
Tomelia1999 and others added 2 commits October 26, 2026 21:52
…from another node (MAG-2280, MAG-4123)

A request pinned with lava-select-provider or lava-stickiness is answered by the pinned
upstream or fails with that upstream's error. The router no longer retries it, hedges it, or
re-selects for it on another node, and no longer drops the pin when its own consistency gate
rejects the pinned node.

Three paths quietly served a pinned request from another node:

- MAG-2280: the pinned node answered with a retryable error (a 429, say) and the retry batch
  dropped the pin (MAG-2228). For the head-then-fetch sequence lava-stickiness exists for,
  the fallback node was never checked to have reached the block, so the retry produced the
  very false gap the header was meant to prevent; and the pin stayed on the failing node for
  the next request.
- MAG-4123: the pre-dispatch consistency gate judged the pinned node too far behind on a
  `latest` call, released it, and the resend ran selection without the pin: another node
  answered with Lava-Retries 0 and nothing in the reply said so.
- The attempt window hedged a slow pinned request onto another node.

Now the policy stops a pinned request after a node error and at the attempt window
(DecisionInput.Pinned, reason "Pinned"); a selection error that names the pin stops the send
stage at once (reason "PinnedProviderUnavailable") instead of after SendRelayAttempts
resends; the consistency gate fails a pinned request with "pinned provider is too far behind
the chain tip" instead of starting an unpinned retry; the pin resolver honours the headers on
every attempt; and a sticky provider that already failed this request is refused like a named
pin, not replaced. The session keeps its node through these failures. A node that keeps
failing is still blocked by the health machinery, which invalidates the pin as before.

Client-visible: a pinned request that used to be served by another node now returns the
pinned node's error (or a router error for the consistency case) with Lava-Retries 0. The
docs that promised the old failover are updated in Magma-Devs/docs#77.

Tests: policy (pinned never retries, hedges or broadcasts; pin errors stop the send stage),
relaycore helpers, the real state machine over the REST mock chain (node error ends on
"Pinned" with one batch; a pin refusal ends on the first send), the gate's terminal error,
and the sticky already-failed refusal. The hedge-accounting end-to-end test steered its first
attempt with a pin; it now uses a fixed selection order, since a pinned request no longer
hedges. Two relay_end goldens carry the new stop reason.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… on request (MAG-2280)

With the pin now holding on every attempt, a caller who prefers a node but would rather get an
answer elsewhere than an error had no way to say so, and the QA suite had no way to steer a first
attempt without forbidding failover. lava-pin-allow-retry: true, sent with lava-select-provider or
lava-stickiness, restores the first-attempt-only pin for that request: retries, hedges and the
consistency gate's retry may reach other providers and are counted in Lava-Retries. The session's
pin is never moved. Only the value "true" (any case) enables it; alone it does nothing.

relaycore.PinAllowsRetry reads it; PinnedByDirectives returns false when it is set, so the policy
and the consistency gate treat the request as unpinned; resolvePinDirectives takes firstAttempt
again and drops the pin on later attempts only for such a request.

Verified locally against the provider simulator: pinned+429 retried on a peer (200, retries 1)
with the header and failed closed without it or with "false"; a slow pinned node hedged with the
header and waited without it; the sticky pin stayed on its node after a retried failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Tomelia1999
Tomelia1999 force-pushed the fix/pinned-requests-never-reroute branch from 7a13ece to f111606 Compare October 26, 2026 19:53
@Tomelia1999
Tomelia1999 marked this pull request as draft October 28, 2026 13:21
@Tomelia1999

Copy link
Copy Markdown
Contributor Author

Blocked until QA agrees. Moved to draft on purpose: this PR changes the pinned-request contract, and the automation suite needs adjusting first. QA task: MAG-4674. It waits for Victoria to confirm that the pin helpers send lava-pin-allow-retry: true and that the pin-contract tests are ready to flip. After that: mark the PR ready, re-dispatch the PR gate on the head, review and merge.

@VicSheCodes

VicSheCodes commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

@Tomelia1999 A proposal that keeps the fix and drops most of the breakage: keep lava-select-provider exactly as it works today, and make only lava-stickiness the hard pin.

The two headers serve two purposes:

  1. lava-select-provider is a steering tool, "start on this node". The product docs promise that a failed node is then retried elsewhere, and callers use it that way.
  2. lava-stickiness exists to keep a series of calls on one node so a customer never sees a false gap. Rerouting it quietly is the bug behind MAG-2280, so strict by default is right there.

What it costs on each side:

  • Router: a few lines. This PR already has the switch, lava-pin-allow-retry. Only its default changes, per header: stickiness strict, select-provider soft as today, with the strict version one extra header away (lava-pin-allow-retry: false, or a dedicated strict header).
  • Docs: the product docs change for stickiness only.
  • Automation: 508 of our 3900 router tests send a pin (counted on automation main at 680f9a5, pytest collection joined to a scan that follows the header through every helper and fixture). 119 test files use lava-select-provider, most of them as the steering tool. With the split they stay untouched. With both headers strict, every one of them needs the opt-out header plus a re-run against this build before anything else can merge, and 287 of the 508 are tests the attached audit on MAG-4674 does not name, so nobody has read them for this change yet.

So the trade is a few lines in this PR against 500-plus tests that are not easy to change, and the customer-facing fix stays exactly as strong for sticky sessions.

The question: can the default be flipped per header in this PR, stickiness strict and select-provider as today?

@VicSheCodes

Copy link
Copy Markdown
Contributor

To be clear on the proposal above: it needs no new header.

  • lava-select-provider stays exactly as it works today: a steering header, and a failed node is still retried elsewhere.
  • lava-stickiness becomes the hard pin: answered by its node, or it fails with that node's error. That is the fix this PR is for.
  • lava-pin-allow-retry, which this PR already adds, flips its role: instead of an opt-out from the hard pin, it is the opt-in to a strict lava-select-provider for anyone who wants that (the value false next to the pin, or a rename if that reads better).

So the only change is which behaviour each existing header gets by default.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants