Skip to content

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

Merged
YutaoMa merged 4 commits into
grpc:masterfrom
LYZJU2019:lyzju2019/xds-transport-channel
Aug 13, 2026
Merged

feat(tonic-xds): drive gRPC retry config from RDS RouteAction.retry_policy#2786
YutaoMa merged 4 commits 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 Envoy retry policy is validated when the RouteConfiguration is validated; the gRPC retry config is compiled once per RDS update and shared behind an Arc, so the request hot path just looks it up and clones that pointer, doing no parsing or allocation.

What changed

  • Validate the retry policy at RDS-validation time; keep gRPC types out of the resource layer. RouteRetryConfig::from_proto validates the Envoy RetryPolicy (gRFC A44 — NACK on numRetries == 0, non-positive baseInterval / maxInterval, or out-of-range google.protobuf.Duration values) and stores the validated, transport-neutral values on the matched route as RouteConfig.retry_config: Option<Arc<RouteRetryConfig>>. 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. The xDS resource types hold no gRPC-specific types.
  • Compile and select the gRPC config per route via the routing decision. A RoutingSnapshot (client layer) bundles the validated RouteConfigResource with a map of GrpcRetrySharedConfigs compiled from it once per RDS update (the XdsRouter watch task), keyed by route-config identity. The routing layer resolves the matched route, looks up its compiled retry config in that same snapshot, and stamps it into the request's RouteDecision. 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. Bundling both in one Arc keeps the request hot path to a map lookup plus a pointer clone.
  • 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), whose route carries no retry policy, or whose retry_on maps to no gRPC status code 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). When the mapped set is empty, the route installs no status-code policy — it falls back to the layer default rather than masking connection retries. 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. The xDS resource types stay free of gRPC business logic: the gRPC retry config is compiled in the client/routing layer, once per RDS update, which 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 (414 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, the RDS-validation rules, and the virtual-host/route precedence: from_route_retry field and default mapping onto GrpcRetrySharedConfig, and its None result when the mapped code set is empty; from_shared instantiating zeroed per-request state while sharing the config by pointer; per-request state independence across cloned policies; RDS validation NACKing invalid retry policies (zero retries, non-positive or out-of-range intervals); and 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
Comment thread tonic-xds/src/xds/resource/route_config.rs Outdated
…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
Comment thread tonic-xds/src/xds/resource/route_config.rs Outdated
Comment thread tonic-xds/src/client/retry.rs
Comment thread tonic-xds/src/xds/resource/route_config.rs Outdated
Comment thread tonic-xds/src/client/retry.rs
Comment thread tonic-xds/src/client/retry.rs Outdated
Compile the gRPC retry config once per RDS update in a new RoutingSnapshot
(client layer), keeping the xDS resource types free of gRPC business logic;
the resource layer now carries only the validated RouteRetryConfig. Routing
and retry read one bundled snapshot, so the request hot path stays a map
lookup plus an Arc clone.

- Reject out-of-range google.protobuf.Duration values and guard backoff
  max-interval math with checked_mul (gRFC A44).
- Skip retry parsing for routes that are dropped during validation.
- Treat an empty parsed retry_on set as "no policy" (return None) so it does
  not mask connection-level retries.
- RetryLayer::new takes the shared config Arc directly.
State the compile-once, single-Arc consistency, and hot-path rationale once on
RoutingSnapshot, and keep the other retry-config sites terse. No code change.
Comment thread tonic-xds/src/client/channel.rs Outdated
Comment thread tonic-xds/src/client/channel.rs Outdated
Address review: build_grpc_channel_from_parts now takes
Arc<GrpcRetrySharedConfig> directly instead of a GrpcRetryPolicy it converts,
and the now test-only retry-config imports live in the tests module.

@YutaoMa YutaoMa 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.

LGTM

@YutaoMa
YutaoMa merged commit a60c3f1 into grpc:master Aug 13, 2026
27 checks passed
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