Skip to main content

aws_smithy_runtime/client/retries/
classifiers.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use aws_smithy_runtime_api::client::interceptors::context::InterceptorContext;
7use aws_smithy_runtime_api::client::retries::classifiers::{
8    ClassifyRetry, RetryAction, RetryClassifierPriority, SharedRetryClassifier,
9};
10use aws_smithy_types::retry::ProvideErrorKind;
11use std::borrow::Cow;
12use std::error::Error as StdError;
13use std::marker::PhantomData;
14
15/// A retry classifier for checking if an error is modeled as retryable.
16#[derive(Debug, Default)]
17pub struct ModeledAsRetryableClassifier<E> {
18    _inner: PhantomData<E>,
19}
20
21impl<E> ModeledAsRetryableClassifier<E> {
22    /// Create a new `ModeledAsRetryableClassifier`
23    pub fn new() -> Self {
24        Self {
25            _inner: PhantomData,
26        }
27    }
28
29    /// Return the priority of this retry classifier.
30    pub fn priority() -> RetryClassifierPriority {
31        RetryClassifierPriority::modeled_as_retryable_classifier()
32    }
33}
34
35impl<E> ClassifyRetry for ModeledAsRetryableClassifier<E>
36where
37    E: StdError + ProvideErrorKind + Send + Sync + 'static,
38{
39    fn classify_retry(&self, ctx: &InterceptorContext) -> RetryAction {
40        // Check for a result
41        let output_or_error = ctx.output_or_error();
42        // Check for an error
43        let error = match output_or_error {
44            Some(Ok(_)) | None => return RetryAction::NoActionIndicated,
45            Some(Err(err)) => err,
46        };
47        // Check that the error is an operation error
48        error
49            .as_operation_error()
50            // Downcast the error
51            .and_then(|err| err.downcast_ref::<E>())
52            // Check if the error is retryable
53            .and_then(|err| err.retryable_error_kind().map(RetryAction::retryable_error))
54            .unwrap_or_default()
55    }
56
57    fn name(&self) -> &'static str {
58        "Errors Modeled As Retryable"
59    }
60
61    fn priority(&self) -> RetryClassifierPriority {
62        Self::priority()
63    }
64}
65
66/// Classifies response, timeout, and connector errors as retryable or not.
67#[derive(Debug, Default)]
68pub struct TransientErrorClassifier<E> {
69    _inner: PhantomData<E>,
70}
71
72impl<E> TransientErrorClassifier<E> {
73    /// Create a new `TransientErrorClassifier`
74    pub fn new() -> Self {
75        Self {
76            _inner: PhantomData,
77        }
78    }
79
80    /// Return the priority of this retry classifier.
81    pub fn priority() -> RetryClassifierPriority {
82        RetryClassifierPriority::transient_error_classifier()
83    }
84}
85
86impl<E> ClassifyRetry for TransientErrorClassifier<E>
87where
88    E: StdError + Send + Sync + 'static,
89{
90    fn classify_retry(&self, ctx: &InterceptorContext) -> RetryAction {
91        // Check for a result
92        let output_or_error = ctx.output_or_error();
93        // Check for an error
94        let error = match output_or_error {
95            Some(Ok(_)) | None => return RetryAction::NoActionIndicated,
96            Some(Err(err)) => err,
97        };
98
99        if error.is_response_error() || error.is_timeout_error() {
100            RetryAction::transient_error()
101        } else if let Some(error) = error.as_connector_error() {
102            if error.is_timeout() || error.is_io() {
103                RetryAction::transient_error()
104            } else {
105                error
106                    .as_other()
107                    .map(RetryAction::retryable_error)
108                    .unwrap_or_default()
109            }
110        } else {
111            RetryAction::NoActionIndicated
112        }
113    }
114
115    fn name(&self) -> &'static str {
116        "Retryable Smithy Errors"
117    }
118
119    fn priority(&self) -> RetryClassifierPriority {
120        Self::priority()
121    }
122}
123
124const TRANSIENT_ERROR_STATUS_CODES: &[u16] = &[500, 502, 503, 504];
125
126/// A retry classifier that will treat HTTP response with those status codes as retryable.
127/// The `Default` version will retry 500, 502, 503, and 504 errors.
128#[derive(Debug)]
129pub struct HttpStatusCodeClassifier {
130    retryable_status_codes: Cow<'static, [u16]>,
131}
132
133impl Default for HttpStatusCodeClassifier {
134    fn default() -> Self {
135        Self::new_from_codes(TRANSIENT_ERROR_STATUS_CODES.to_owned())
136    }
137}
138
139impl HttpStatusCodeClassifier {
140    /// Given a `Vec<u16>` where the `u16`s represent status codes, create a `HttpStatusCodeClassifier`
141    /// that will treat HTTP response with those status codes as retryable. The `Default` version
142    /// will retry 500, 502, 503, and 504 errors.
143    pub fn new_from_codes(retryable_status_codes: impl Into<Cow<'static, [u16]>>) -> Self {
144        Self {
145            retryable_status_codes: retryable_status_codes.into(),
146        }
147    }
148
149    /// Return the priority of this retry classifier.
150    pub fn priority() -> RetryClassifierPriority {
151        RetryClassifierPriority::http_status_code_classifier()
152    }
153}
154
155impl ClassifyRetry for HttpStatusCodeClassifier {
156    fn classify_retry(&self, ctx: &InterceptorContext) -> RetryAction {
157        let is_retryable = ctx
158            .response()
159            .map(|res| res.status().into())
160            .map(|status| self.retryable_status_codes.contains(&status))
161            .unwrap_or_default();
162
163        if is_retryable {
164            RetryAction::transient_error()
165        } else {
166            RetryAction::NoActionIndicated
167        }
168    }
169
170    fn name(&self) -> &'static str {
171        "HTTP Status Code"
172    }
173
174    fn priority(&self) -> RetryClassifierPriority {
175        Self::priority()
176    }
177}
178
179/// Given an iterator of retry classifiers and an interceptor context, run retry classifiers on the
180/// context. Each classifier is passed the [`RetryAction`] accumulated by the previously-run
181/// classifiers (the first classifier is passed [`RetryAction::NoActionIndicated`]) via
182/// [`ClassifyRetry::classify_retry_v2`], so a later classifier may refine an earlier verdict.
183pub fn run_classifiers_on_ctx(
184    classifiers: impl Iterator<Item = SharedRetryClassifier>,
185    ctx: &InterceptorContext,
186) -> RetryAction {
187    // By default, don't retry
188    let mut result = RetryAction::NoActionIndicated;
189
190    for classifier in classifiers {
191        let new_result = classifier.classify_retry_v2(ctx, &result);
192
193        // If the result is `NoActionIndicated`, continue to the next classifier
194        // without overriding any previously-set result.
195        if new_result == RetryAction::NoActionIndicated {
196            continue;
197        }
198
199        // Otherwise, set the result to the new result.
200        tracing::trace!(
201            "Classifier '{}' set the result of classification to '{}'",
202            classifier.name(),
203            new_result
204        );
205        result = new_result;
206
207        // If the result is `RetryForbidden`, stop running classifiers.
208        if result == RetryAction::RetryForbidden {
209            tracing::trace!("retry classification ending early because a `RetryAction::RetryForbidden` was emitted",);
210            break;
211        }
212    }
213
214    result
215}
216
217#[cfg(test)]
218mod test {
219    use crate::client::retries::classifiers::{
220        HttpStatusCodeClassifier, ModeledAsRetryableClassifier,
221    };
222    use aws_smithy_runtime_api::client::interceptors::context::{Error, Input, InterceptorContext};
223    use aws_smithy_runtime_api::client::orchestrator::OrchestratorError;
224    use aws_smithy_runtime_api::client::retries::classifiers::{
225        ClassifyRetry, RetryAction, RetryReason, SharedRetryClassifier,
226    };
227    use aws_smithy_types::body::SdkBody;
228    use aws_smithy_types::retry::{ErrorKind, ProvideErrorKind};
229    use std::fmt;
230    use std::time::Duration;
231
232    use super::{run_classifiers_on_ctx, TransientErrorClassifier};
233
234    #[derive(Debug, PartialEq, Eq, Clone)]
235    struct UnmodeledError;
236
237    impl fmt::Display for UnmodeledError {
238        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239            write!(f, "UnmodeledError")
240        }
241    }
242
243    impl std::error::Error for UnmodeledError {}
244
245    #[test]
246    fn classify_by_response_status() {
247        let policy = HttpStatusCodeClassifier::default();
248        let res = http_1x::Response::builder()
249            .status(500)
250            .body("error!")
251            .unwrap()
252            .map(SdkBody::from);
253        let mut ctx = InterceptorContext::new(Input::doesnt_matter());
254        ctx.set_response(res.try_into().unwrap());
255        assert_eq!(policy.classify_retry(&ctx), RetryAction::transient_error());
256    }
257
258    #[test]
259    fn classify_by_response_status_not_retryable() {
260        let policy = HttpStatusCodeClassifier::default();
261        let res = http_1x::Response::builder()
262            .status(408)
263            .body("error!")
264            .unwrap()
265            .map(SdkBody::from);
266        let mut ctx = InterceptorContext::new(Input::doesnt_matter());
267        ctx.set_response(res.try_into().unwrap());
268        assert_eq!(policy.classify_retry(&ctx), RetryAction::NoActionIndicated);
269    }
270
271    #[test]
272    fn classify_by_error_kind() {
273        #[derive(Debug)]
274        struct RetryableError;
275
276        impl fmt::Display for RetryableError {
277            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278                write!(f, "Some retryable error")
279            }
280        }
281
282        impl ProvideErrorKind for RetryableError {
283            fn retryable_error_kind(&self) -> Option<ErrorKind> {
284                Some(ErrorKind::ClientError)
285            }
286
287            fn code(&self) -> Option<&str> {
288                // code should not be called when `error_kind` is provided
289                unimplemented!()
290            }
291        }
292
293        impl std::error::Error for RetryableError {}
294
295        let policy = ModeledAsRetryableClassifier::<RetryableError>::new();
296        let mut ctx = InterceptorContext::new(Input::doesnt_matter());
297        ctx.set_output_or_error(Err(OrchestratorError::operation(Error::erase(
298            RetryableError,
299        ))));
300
301        assert_eq!(policy.classify_retry(&ctx), RetryAction::client_error(),);
302    }
303
304    #[test]
305    fn classify_response_error() {
306        let policy = TransientErrorClassifier::<UnmodeledError>::new();
307        let mut ctx = InterceptorContext::new(Input::doesnt_matter());
308        ctx.set_output_or_error(Err(OrchestratorError::response(
309            "I am a response error".into(),
310        )));
311        assert_eq!(policy.classify_retry(&ctx), RetryAction::transient_error(),);
312    }
313
314    #[test]
315    fn test_timeout_error() {
316        let policy = TransientErrorClassifier::<UnmodeledError>::new();
317        let mut ctx = InterceptorContext::new(Input::doesnt_matter());
318        ctx.set_output_or_error(Err(OrchestratorError::timeout(
319            "I am a timeout error".into(),
320        )));
321        assert_eq!(policy.classify_retry(&ctx), RetryAction::transient_error(),);
322    }
323
324    // A classifier that returns a fixed verdict from `classify_retry` (and thus,
325    // via the default impl, from `classify_retry_v2`).
326    #[derive(Debug)]
327    struct StaticClassifier {
328        name: &'static str,
329        action: RetryAction,
330    }
331
332    impl ClassifyRetry for StaticClassifier {
333        fn classify_retry(&self, _ctx: &InterceptorContext) -> RetryAction {
334            self.action.clone()
335        }
336
337        fn name(&self) -> &'static str {
338            self.name
339        }
340    }
341
342    // A classifier that refines an already-retryable verdict by attaching a
343    // fixed delay, mimicking how `AwsErrorCodeClassifier` tucks an
344    // `x-amz-retry-after` delay into a verdict produced by an earlier-running
345    // classifier. It only refines a retryable `previous` that has no delay yet;
346    // otherwise it abstains with `NoActionIndicated`.
347    #[derive(Debug)]
348    struct DelayRefiningClassifier;
349
350    impl ClassifyRetry for DelayRefiningClassifier {
351        fn classify_retry(&self, _ctx: &InterceptorContext) -> RetryAction {
352            RetryAction::NoActionIndicated
353        }
354
355        fn classify_retry_v2(
356            &self,
357            _ctx: &InterceptorContext,
358            previous: &RetryAction,
359        ) -> RetryAction {
360            if let RetryAction::RetryIndicated(RetryReason::RetryableError {
361                kind,
362                retry_after: None,
363            }) = previous
364            {
365                RetryAction::retryable_error_with_explicit_delay(*kind, Duration::from_secs(5))
366            } else {
367                RetryAction::NoActionIndicated
368            }
369        }
370
371        fn name(&self) -> &'static str {
372            "delay-refining"
373        }
374    }
375
376    // `run_classifiers_on_ctx` passes each classifier the verdict accumulated by
377    // the previously-run classifiers via `classify_retry_v2`, so a later
378    // classifier can refine an earlier one's verdict (attaching a delay here).
379    #[test]
380    fn run_classifiers_on_ctx_lets_later_classifier_refine_earlier_verdict() {
381        let ctx = InterceptorContext::new(Input::doesnt_matter());
382        let classifiers = vec![
383            SharedRetryClassifier::new(StaticClassifier {
384                name: "transient",
385                action: RetryAction::transient_error(),
386            }),
387            SharedRetryClassifier::new(DelayRefiningClassifier),
388        ];
389
390        assert_eq!(
391            run_classifiers_on_ctx(classifiers.into_iter(), &ctx),
392            RetryAction::retryable_error_with_explicit_delay(
393                ErrorKind::TransientError,
394                Duration::from_secs(5),
395            ),
396        );
397    }
398
399    // The refining classifier must not fabricate a retry on its own: if no
400    // earlier classifier marked the response retryable, the refinement abstains
401    // and the overall result is `NoActionIndicated`. This matches the behavior
402    // of other SDKs (e.g. Java v2), where `x-amz-retry-after` only supplies a
403    // backoff delay for an already-retryable error and never forces a retry.
404    #[test]
405    fn run_classifiers_on_ctx_refiner_does_not_fabricate_retry() {
406        let ctx = InterceptorContext::new(Input::doesnt_matter());
407        let classifiers = vec![SharedRetryClassifier::new(DelayRefiningClassifier)];
408
409        assert_eq!(
410            run_classifiers_on_ctx(classifiers.into_iter(), &ctx),
411            RetryAction::NoActionIndicated,
412        );
413    }
414
415    // When the refining classifier abstains (returns `NoActionIndicated` because
416    // the earlier verdict already carries a delay), `run_classifiers_on_ctx`
417    // preserves that earlier verdict rather than clobbering it.
418    #[test]
419    fn run_classifiers_on_ctx_preserves_earlier_verdict_when_refiner_abstains() {
420        let ctx = InterceptorContext::new(Input::doesnt_matter());
421        let already_delayed = RetryAction::retryable_error_with_explicit_delay(
422            ErrorKind::TransientError,
423            Duration::from_secs(1),
424        );
425        let classifiers = vec![
426            SharedRetryClassifier::new(StaticClassifier {
427                name: "already-delayed",
428                action: already_delayed.clone(),
429            }),
430            SharedRetryClassifier::new(DelayRefiningClassifier),
431        ];
432
433        assert_eq!(
434            run_classifiers_on_ctx(classifiers.into_iter(), &ctx),
435            already_delayed,
436        );
437    }
438}