google_cloud_spanner/retry_policy.rs
1// Copyright 2026 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! RPC retry policies used by the Spanner client.
16
17use google_cloud_gax::error::Error;
18use google_cloud_gax::retry_policy::{Aip194Strict, RetryPolicy};
19use google_cloud_gax::retry_result::RetryResult;
20use google_cloud_gax::retry_state::RetryState;
21use google_cloud_gax::throttle_result::ThrottleResult;
22use std::time::Duration;
23
24/// The retry policy the Spanner client applies to RPCs that do not configure
25/// their own. It decorates/extends [google_cloud_gax::retry_policy::Aip194Strict].
26///
27/// Like `Aip194Strict`, this policy retries `UNAVAILABLE` errors and transient
28/// failures that occur before the request reaches the service, but only for
29/// idempotent requests. In addition — because Spanner allows transport and
30/// connection errors to be retried on idempotent operations — it also retries
31/// transport/network and I/O errors that `Aip194Strict` would classify as
32/// permanent (such as a connection dropped after the request was sent), again
33/// only if the request is idempotent.
34///
35/// The policy places no limit on the number of attempts or the elapsed time.
36/// Applications that want to bound the client's default retry behavior can
37/// decorate this policy with
38/// [RetryPolicyExt][google_cloud_gax::retry_policy::RetryPolicyExt] instead of
39/// re-implementing its error classification:
40///
41/// # Example
42/// ```
43/// # use std::time::Duration;
44/// # use google_cloud_spanner::retry_policy::SpannerRetryPolicy;
45/// # use google_cloud_spanner::statement::Statement;
46/// # use google_cloud_gax::retry_policy::RetryPolicyExt;
47/// let statement = Statement::builder("SELECT * FROM Users")
48/// .with_retry_policy(
49/// SpannerRetryPolicy::new()
50/// .with_attempt_limit(5)
51/// .with_time_limit(Duration::from_secs(30)),
52/// )
53/// .build();
54/// ```
55#[derive(Clone, Debug)]
56pub struct SpannerRetryPolicy {
57 inner: Aip194Strict,
58}
59
60impl SpannerRetryPolicy {
61 /// Creates a new Spanner retry policy.
62 pub fn new() -> Self {
63 Self {
64 inner: Aip194Strict,
65 }
66 }
67}
68
69impl Default for SpannerRetryPolicy {
70 fn default() -> Self {
71 Self::new()
72 }
73}
74
75impl RetryPolicy for SpannerRetryPolicy {
76 fn on_error(&self, state: &RetryState, error: Error) -> RetryResult {
77 // 1. Strict AIP-194 checks (Unavailable, is_transient_and_before_rpc)
78 let result = self.inner.on_error(state, error);
79 match result {
80 // If the strict AIP-194 checks classified the error as permanent (such as a transport
81 // error that occurred post-headers), we override it to Continue if the request is idempotent.
82 RetryResult::Permanent(error)
83 if state.idempotent && (error.is_transport() || error.is_io()) =>
84 {
85 RetryResult::Continue(error)
86 }
87 res => res,
88 }
89 }
90
91 fn on_throttle(&self, state: &RetryState, error: Error) -> ThrottleResult {
92 self.inner.on_throttle(state, error)
93 }
94
95 fn remaining_time(&self, state: &RetryState) -> Option<Duration> {
96 self.inner.remaining_time(state)
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use google_cloud_gax::error::Error as GaxError;
104 use google_cloud_gax::error::rpc::{Code, Status};
105 use http::HeaderMap;
106
107 #[test]
108 fn test_spanner_retry_policy_idempotent() {
109 let policy = SpannerRetryPolicy::new();
110 let state = RetryState::new(true); // idempotent = true
111
112 // 1. Service UNAVAILABLE error should be retried (via inner AIP-194)
113 let status = Status::default()
114 .set_code(Code::Unavailable)
115 .set_message("Service Unavailable");
116 let err = GaxError::service(status);
117 assert!(
118 policy.on_error(&state, err).is_continue(),
119 "Expected UNAVAILABLE to be retried when idempotent"
120 );
121
122 // 2. Service PERMISSION_DENIED error should not be retried
123 let status = Status::default()
124 .set_code(Code::PermissionDenied)
125 .set_message("Denied");
126 let err = GaxError::service(status);
127 assert!(
128 policy.on_error(&state, err).is_permanent(),
129 "Expected PERMISSION_DENIED to not be retried"
130 );
131
132 // 3. IO/Transport error should be retried when idempotent
133 let err = GaxError::transport(
134 HeaderMap::new(),
135 std::io::Error::new(std::io::ErrorKind::ConnectionReset, "connection closed"),
136 );
137 assert!(
138 policy.on_error(&state, err).is_continue(),
139 "Expected transport connection reset to be retried when idempotent"
140 );
141 }
142
143 #[test]
144 fn test_spanner_retry_policy_non_idempotent() {
145 let policy = SpannerRetryPolicy::new();
146 let state = RetryState::new(false); // idempotent = false
147
148 // 1. Service UNAVAILABLE error should NOT be retried (AIP-194 requires idempotency)
149 let status = Status::default()
150 .set_code(Code::Unavailable)
151 .set_message("Service Unavailable");
152 let err = GaxError::service(status);
153 assert!(
154 policy.on_error(&state, err).is_permanent(),
155 "Expected UNAVAILABLE to be permanent when non-idempotent"
156 );
157
158 // 2. IO/Transport error should NOT be retried when non-idempotent
159 let err = GaxError::transport(
160 HeaderMap::new(),
161 std::io::Error::new(std::io::ErrorKind::ConnectionReset, "connection closed"),
162 );
163 assert!(
164 policy.on_error(&state, err).is_permanent(),
165 "Expected transport connection reset to not be retried when non-idempotent"
166 );
167 }
168}