Skip to content

feat(tonic-xds): drive gRPC retry config from RDS RouteAction.retry_policy - #2786

Open
LYZJU2019 wants to merge 1 commit into
grpc:masterfrom
LYZJU2019:lyzju2019/xds-transport-channel
Open

feat(tonic-xds): drive gRPC retry config from RDS RouteAction.retry_policy#2786
LYZJU2019 wants to merge 1 commit into
grpc:masterfrom
LYZJU2019:lyzju2019/xds-transport-channel

Conversation

@LYZJU2019

@LYZJU2019 LYZJU2019 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Drives the gRPC channel's retry configuration from the control plane so retry behavior tracks RDS (RouteConfiguration) updates without rebuilding the channel.

The retry settings come from the standard Envoy RouteAction.retry_policy (gRFC A44), so tonic-xds parses them natively — no caller-supplied extractor closure is required. This is additive and non-breaking; there is no public API change.

Built on top of #2794. Retry is per route: each request retries according to the exact route it matched. The gRPC retry config is parsed and validated once, when the RouteConfiguration is validated, and shared behind an Arc; the request hot path just clones that pointer and initializes per-request state, doing no parsing or allocation.

What changed

  • Parse and validate the retry policy at RDS-validation time. validate_route maps the Envoy retry_on conditions to gRPC status codes, applies defaults for unset fields, and stores the result as an immutable, Arc-shared GrpcRetrySharedConfig on the matched route (RouteConfig.retry_config). A route uses its own RouteAction.retry_policy when set, otherwise it inherits the enclosing VirtualHost.retry_policy (gRFC A44: a route-level policy completely overrides the virtual host's — values are not merged); None when neither specifies retry, and routes inheriting the vhost policy share one Arc. RouteRetryConfig remains a transport-neutral carrier for the raw Envoy values during parsing. Per-route granularity: each route keeps its own config.
  • Select the config per route via the routing decision. The routing layer stamps the matched route's shared retry config into the request's RouteDecision, taken from the same config snapshot it routed with. Because routing and retry read one snapshot, they always act on the same RDS version (no cross-layer skew), and retry runs inside routing so the decision is fixed across a request's retry attempts.
  • Separate the shared config from per-request state. RetrySharedConfig<C> holds the immutable config (attempt cap, backoff, retryable code set) behind an Arc; RetryPolicy<C> holds a pointer to it plus the per-request retry state (backoff cursor, attempt count). Instantiating a policy for a request (RetryPolicy::from_shared) is an Arc pointer clone plus a zero-field state init — the request hot path does no parsing or allocation. Requests with no RouteDecision (non-xDS callers) or whose route carries no retry policy use the layer's fallback config.
  • Map retry_on to gRPC status codes. grpc_retry_on_codes maps Envoy retry_on conditions to gRPC status codes (gRFC A44); non-gRPC tokens are ignored (connection-level retries are handled separately). Envoy numRetries maps directly to RetryConfig.num_retries (retries, not attempts); unset Envoy fields fall back to RetryConfig defaults.

The retry engine (RetryPolicy, RetrySharedConfig, and the RetryClassifier seam) stays transport-agnostic; only the retry layer is gRPC-specific, because it reads the concrete RouteDecision extension. Deriving and validating the gRPC retry config once at RDS-validation time — rather than in the transport-neutral resource type — is what keeps the request path allocation-free.

Example RDS consumed

{
  "@type": "type.googleapis.com/envoy.config.route.v3.RouteConfiguration",
  "name": "AccessControlApi",
  "virtualHosts": [{
    "name": "AccessControlApi",
    "domains": ["*"],
    "routes": [{
      "match": { "prefix": "" },
      "route": {
        "cluster": "AgentLifecycleGrpc|0",
        "timeout": "60s",
        "retryPolicy": {
          "retryOn": "unavailable",
          "numRetries": 2,
          "retryBackOff": { "baseInterval": "0.100s", "maxInterval": "1s" }
        }
      }
    }]
  }]
}

The retryPolicy may equivalently be set at the virtualHosts[*] level, in which case every route in the virtual host that doesn't set its own inherits it.

Testing

cargo +1.97.0 with --features tls-ring,testutil:

  • fmt --check, check (lib + tests), test (405 unit + 4 doc), doc with RUSTDOCFLAGS="-D warnings" (default + tls-ring), clippy (no new warnings)
  • cargo +nightly-2025-10-18 check-external-types --all-features (no allowlist change — no public API change)

New unit tests cover the mapping, the split config/state model, and the virtual-host/route precedence: from_route_retry field and default mapping onto GrpcRetrySharedConfig; from_shared instantiating zeroed per-request state while sharing the config by pointer; per-request state independence across cloned policies; and, at RDS-validation time, a route overriding the virtual-host policy while sibling routes with no policy share the inherited config Arc (plus the no-policy-anywhere case yielding None). test_retry_once_on_unavailable exercises a real retry with retry_on = "unavailable".

@LYZJU2019
LYZJU2019 force-pushed the lyzju2019/xds-transport-channel branch from 99cfc82 to bf14624 Compare July 31, 2026 19:13
@LYZJU2019 LYZJU2019 changed the title feat(tonic-xds): add transport-generic build_transport_channel feat(tonic-xds): drive gRPC retry config from LDS control-plane updates Jul 31, 2026
@LYZJU2019
LYZJU2019 force-pushed the lyzju2019/xds-transport-channel branch from bf14624 to 48d06bb Compare August 6, 2026 22:55
@LYZJU2019 LYZJU2019 changed the title feat(tonic-xds): drive gRPC retry config from LDS control-plane updates feat(tonic-xds): drive gRPC retry config from RDS RouteAction.retry_policy Aug 6, 2026
@LYZJU2019
LYZJU2019 force-pushed the lyzju2019/xds-transport-channel branch 4 times, most recently from bd95f5c to f8015d3 Compare August 12, 2026 18:39
@YutaoMa

YutaoMa commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

A few issues with the current code that require fundamental design change:

  1. I see the newer commits reverted back to a dedicated watch task in the retry layer. It is still more ideal to preserve config consistency across the two layers through request extension passing.
  2. The retry config field in VirtualHost level isn't read. According to A44, both VH and Route level retry config are respected, with the Route level taking priority.
  3. A recommendation to keep the design both completes A44 and efficient on the request path: extract and validate retry config at resource validation time. And separate out the per-request retry state (current backoff, etc.) from the shared config (limit, retryOn, etc.). Pass down shared config through Arc pointers, and instantiate the state in retry layer per request, which would be simplified to only a pointer clone and some zero-field init.

Let me know if you have any questions re: the design proposal.

@LYZJU2019
LYZJU2019 force-pushed the lyzju2019/xds-transport-channel branch from f8015d3 to c3261a3 Compare August 12, 2026 22:29
fn from_proto(rp: &RetryPolicy) -> Self {
let (base_interval, max_interval) = match rp.retry_back_off.as_ref() {
Some(backoff) => (
backoff.base_interval.as_ref().and_then(proto_duration),

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.

In A44, some limits are given for these fields, such as "base_interval and max_interval must be greater than zero", the resource should fail validation for those conditions, so the XDS client can properly NACK according to the spec.

…olicy

Wire the gRPC channel's retry configuration to the control plane so retry
behavior tracks RDS (RouteConfiguration) updates without rebuilding the
channel. The retry settings come from the standard Envoy
`RouteAction.retry_policy` (gRFC A44), so OSS parses them natively — no
caller-supplied extractor is needed. Retry is per route: each request retries
according to the exact route it matched.

- Parse and validate the retry policy when the RDS resource is validated —
  once, not per request. `validate_route` maps the Envoy `retry_on` conditions
  to gRPC status codes, applies defaults for unset fields, and stores the
  result as an immutable, `Arc`-shared `GrpcRetrySharedConfig` on the matched
  route (`RouteConfig.retry_config`). A route uses its own
  `RouteAction.retry_policy` when set, otherwise it inherits the enclosing
  `VirtualHost.retry_policy` (gRFC A44: a route-level policy completely
  overrides the virtual host's — values are not merged). `None` when neither
  specifies retry; routes inheriting the vhost policy share one `Arc`.
  `RouteRetryConfig` remains a transport-neutral carrier for the raw Envoy
  values during parsing.
- Select the config per route via the routing decision. The routing layer
  stamps the matched route's shared retry config into the request's
  `RouteDecision`, taken from the same config snapshot it routed with. Because
  routing and retry read one snapshot, they always act on the same RDS version
  (no cross-layer skew), and retry runs inside routing so the decision is
  fixed across a request's retry attempts.
- Separate the shared, immutable retry config (attempt cap, backoff, retryable
  code set) from the per-request retry state (backoff cursor, attempt count).
  `RetrySharedConfig<C>` holds the config behind an `Arc`; `RetryPolicy<C>`
  holds a pointer to it plus the per-request state. Instantiating a policy for
  a request (`RetryPolicy::from_shared`) is an `Arc` pointer clone plus a
  zero-field state init — the request hot path does no parsing or allocation.
  Requests with no `RouteDecision` (non-xDS callers) or whose route carries no
  retry policy use the layer's fallback config.
- Map Envoy `retry_on` conditions to gRPC status codes (gRFC A44) with
  `grpc_retry_on_codes`; non-gRPC tokens are ignored (connection-level retries
  are handled separately). Envoy `numRetries` maps directly to
  `RetryConfig.num_retries` (retries, not attempts).

The retry engine (`RetryPolicy`, `RetrySharedConfig`, and the `RetryClassifier`
seam) stays transport-agnostic; only the retry layer is gRPC-specific, because
it reads the concrete `RouteDecision` extension. Deriving and validating the
gRPC retry config once at RDS-validation time — rather than in the transport-
neutral resource type — is what keeps the request path allocation-free.
@LYZJU2019
LYZJU2019 force-pushed the lyzju2019/xds-transport-channel branch from c3261a3 to 65b6b20 Compare August 12, 2026 23:17
// allocation (see `RouteConfig::retry_config`). A route-level policy
// takes precedence over the virtual host's (gRFC A44); the vhost
// fallback is applied below.
retry_config = route_action.retry_policy.as_ref().map(|rp| {

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.

you can early return on None here to save a unnecessary parse.

config = config.num_retries(num_retries);
}
if let Some(base_interval) = retry.base_interval {
let mut backoff = RetryBackoffConfig::new(base_interval);

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.

proto_duration has no range check andRetryBackoffConfig::new  does base_interval * 10 , so a very large base_interval can panic. I recommend adding a range check in proto_duration to reject invalid duration and also make RetryBackoffConfig::new use checked_mul. (For context, the documented protobuf.Duration type max should be 315_576_000_000, just that the Rust envoy_types type itself doesn't enforce that invariant. It's safe to NACK the config if the provided value exceeds that)

use xds_client::{Error, Resource};

use super::string_matcher::StringMatcher;
use crate::client::retry::GrpcRetrySharedConfig;

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.

actually although I mentioned previously that we should minimize request path allocation and compute, it'd be too aggressive to put grpc business logic types into the xds resource types, it violates module boundaries. Let's still keep the validated proto -> actual grpc config type in the retry layer, it will be done only once per RDS update anyway.

/// (e.g. `5xx`, `gateway-error`, `reset`, `connect-failure`) are ignored because
/// connection-level retries are handled separately by
/// [`is_retryable_connection_error`].
pub(crate) fn grpc_retry_on_codes(retry_on: &str) -> Vec<tonic::Code> {

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.

A44 has a minor behavior detail: If the resulting retryableStatusCodes is empty, gRPC xDS client will not include a retry policy in the corresponding route action, which this code currently doesn't do, it instead sets an empty policy. This matters because connection errors are also dependent on the retry policy, so right now an empty retryableStatusCodes = empty policy = disable connection retry. I checked other gRPC implementations and confirmed connection retry should not be masked by empty retryableStatusCodes. To keep it aligned with gRPC retry behavior we should return a None at policy level when retry_on is empty.

/// carries no per-route retry config. Only the policy's shared config is
/// kept; its per-request state is discarded. Route-specific configs are read
/// from the request's [`RouteDecision`] (see [`RetryLayer`]).
pub(crate) fn new(fallback: GrpcRetryPolicy) -> Self {

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.

since we only need the shared config here, maybe take the Arc<SharedConfig> as the param?

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.

2 participants