Skip to main content

aws_runtime/
request_info.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use crate::service_clock_skew::AttemptSkew;
7use aws_smithy_async::time::TimeSource;
8use aws_smithy_runtime_api::box_error::BoxError;
9use aws_smithy_runtime_api::client::interceptors::context::BeforeTransmitInterceptorContextMut;
10use aws_smithy_runtime_api::client::interceptors::{dyn_dispatch_hint, Intercept};
11use aws_smithy_runtime_api::client::retries::RequestAttempts;
12use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
13use aws_smithy_types::config_bag::ConfigBag;
14use aws_smithy_types::date_time::Format;
15use aws_smithy_types::retry::RetryConfig;
16use aws_smithy_types::timeout::TimeoutConfig;
17use aws_smithy_types::DateTime;
18use http_1x::{HeaderName, HeaderValue};
19use std::borrow::Cow;
20
21#[allow(clippy::declare_interior_mutable_const)] // we will never mutate this
22const AMZ_SDK_REQUEST: HeaderName = HeaderName::from_static("amz-sdk-request");
23
24/// Generates and attaches a request header that communicates request-related metadata.
25/// Examples include:
26///
27/// - When the client will time out this request.
28/// - How many times the request has been retried.
29/// - The maximum number of retries that the client will attempt.
30#[non_exhaustive]
31#[derive(Debug, Default)]
32pub struct RequestInfoInterceptor {}
33
34impl RequestInfoInterceptor {
35    /// Creates a new `RequestInfoInterceptor`
36    pub fn new() -> Self {
37        RequestInfoInterceptor {}
38    }
39}
40
41impl RequestInfoInterceptor {
42    fn build_attempts_pair(
43        &self,
44        cfg: &ConfigBag,
45    ) -> Option<(Cow<'static, str>, Cow<'static, str>)> {
46        let request_attempts = cfg
47            .load::<RequestAttempts>()
48            .map(|r_a| r_a.attempts())
49            .unwrap_or(0);
50        let request_attempts = request_attempts.to_string();
51        Some((Cow::Borrowed("attempt"), Cow::Owned(request_attempts)))
52    }
53
54    fn build_max_attempts_pair(
55        &self,
56        cfg: &ConfigBag,
57    ) -> Option<(Cow<'static, str>, Cow<'static, str>)> {
58        if let Some(retry_config) = cfg.load::<RetryConfig>() {
59            let max_attempts = retry_config.max_attempts().to_string();
60            Some((Cow::Borrowed("max"), Cow::Owned(max_attempts)))
61        } else {
62            None
63        }
64    }
65
66    fn build_ttl_pair(
67        &self,
68        cfg: &ConfigBag,
69        timesource: impl TimeSource,
70    ) -> Option<(Cow<'static, str>, Cow<'static, str>)> {
71        let timeout_config = cfg.load::<TimeoutConfig>()?;
72        let socket_read = timeout_config.read_timeout()?;
73        // Preserve prior behavior: emit `ttl` only on retries, not the initial attempt.
74        if cfg
75            .load::<RequestAttempts>()
76            .map(|a| a.attempts())
77            .unwrap_or(0)
78            < 2
79        {
80            return None;
81        }
82        // Zero skew when correction is disabled/unknown: keep the TTL hint, just unadjusted.
83        let skew = cfg.load::<AttemptSkew>().map(|s| s.0).unwrap_or_default();
84        let ttl = skew.apply(timesource.now()).checked_add(socket_read)?;
85        let mut timestamp = DateTime::from(ttl);
86        // Set subsec_nanos to 0 so that the formatted `DateTime` won't have fractional seconds.
87        timestamp.set_subsec_nanos(0);
88        let mut formatted_timestamp = timestamp
89            .fmt(Format::DateTime)
90            .expect("the resulting DateTime will always be valid");
91
92        // Remove dashes and colons
93        formatted_timestamp = formatted_timestamp
94            .chars()
95            .filter(|&c| c != '-' && c != ':')
96            .collect();
97
98        Some((Cow::Borrowed("ttl"), Cow::Owned(formatted_timestamp)))
99    }
100}
101
102#[dyn_dispatch_hint]
103impl Intercept for RequestInfoInterceptor {
104    fn name(&self) -> &'static str {
105        "RequestInfoInterceptor"
106    }
107
108    fn modify_before_transmit(
109        &self,
110        context: &mut BeforeTransmitInterceptorContextMut<'_>,
111        runtime_components: &RuntimeComponents,
112        cfg: &mut ConfigBag,
113    ) -> Result<(), BoxError> {
114        let mut pairs = RequestPairs::new();
115        if let Some(pair) = self.build_ttl_pair(
116            cfg,
117            runtime_components
118                .time_source()
119                .ok_or("A timesource must be provided")?,
120        ) {
121            pairs = pairs.with_pair(pair);
122        }
123        if let Some(pair) = self.build_attempts_pair(cfg) {
124            pairs = pairs.with_pair(pair);
125        }
126        if let Some(pair) = self.build_max_attempts_pair(cfg) {
127            pairs = pairs.with_pair(pair);
128        }
129
130        let headers = context.request_mut().headers_mut();
131        headers.insert(AMZ_SDK_REQUEST, pairs.try_into_header_value()?);
132
133        Ok(())
134    }
135}
136
137/// A builder for creating a `RequestPairs` header value. `RequestPairs` is used to generate a
138/// retry information header that is sent with every request. The information conveyed by this
139/// header allows services to anticipate whether a client will time out or retry a request.
140#[derive(Default, Debug)]
141struct RequestPairs {
142    inner: Vec<(Cow<'static, str>, Cow<'static, str>)>,
143}
144
145impl RequestPairs {
146    /// Creates a new `RequestPairs` builder.
147    fn new() -> Self {
148        Default::default()
149    }
150
151    /// Adds a pair to the `RequestPairs` builder.
152    /// Only strings that can be converted to header values are considered valid.
153    fn with_pair(
154        mut self,
155        pair: (impl Into<Cow<'static, str>>, impl Into<Cow<'static, str>>),
156    ) -> Self {
157        let pair = (pair.0.into(), pair.1.into());
158        self.inner.push(pair);
159        self
160    }
161
162    /// Converts the `RequestPairs` builder into a `HeaderValue`.
163    fn try_into_header_value(self) -> Result<HeaderValue, BoxError> {
164        self.try_into()
165    }
166}
167
168impl TryFrom<RequestPairs> for HeaderValue {
169    type Error = BoxError;
170
171    fn try_from(value: RequestPairs) -> Result<Self, BoxError> {
172        let mut pairs = String::new();
173        for (key, value) in value.inner {
174            if !pairs.is_empty() {
175                pairs.push_str("; ");
176            }
177
178            pairs.push_str(&key);
179            pairs.push('=');
180            pairs.push_str(&value);
181            continue;
182        }
183        HeaderValue::from_str(&pairs).map_err(Into::into)
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::RequestInfoInterceptor;
190    use crate::request_info::RequestPairs;
191    use aws_smithy_runtime_api::client::interceptors::context::Input;
192    use aws_smithy_runtime_api::client::interceptors::context::InterceptorContext;
193    use aws_smithy_runtime_api::client::interceptors::Intercept;
194    use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
195    use aws_smithy_runtime_api::client::runtime_components::RuntimeComponentsBuilder;
196    use aws_smithy_types::config_bag::{ConfigBag, Layer};
197    use aws_smithy_types::retry::RetryConfig;
198    use aws_smithy_types::timeout::TimeoutConfig;
199
200    use http_1x::HeaderValue;
201    use std::time::Duration;
202
203    fn expect_header<'a>(context: &'a InterceptorContext, header_name: &str) -> &'a str {
204        context
205            .request()
206            .expect("request is set")
207            .headers()
208            .get(header_name)
209            .unwrap()
210    }
211
212    #[test]
213    fn test_request_pairs_for_initial_attempt() {
214        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
215        let mut context = InterceptorContext::new(Input::doesnt_matter());
216        context.enter_serialization_phase();
217        context.set_request(HttpRequest::empty());
218
219        let mut layer = Layer::new("test");
220        layer.store_put(RetryConfig::standard());
221        layer.store_put(
222            TimeoutConfig::builder()
223                .read_timeout(Duration::from_secs(30))
224                .build(),
225        );
226        let mut config = ConfigBag::of_layers(vec![layer]);
227
228        let _ = context.take_input();
229        context.enter_before_transmit_phase();
230        let interceptor = RequestInfoInterceptor::new();
231        let mut ctx = (&mut context).into();
232        interceptor
233            .modify_before_transmit(&mut ctx, &rc, &mut config)
234            .unwrap();
235
236        assert_eq!(
237            expect_header(&context, "amz-sdk-request"),
238            "attempt=0; max=3"
239        );
240    }
241
242    #[test]
243    fn test_header_value_from_request_pairs_supports_all_valid_characters() {
244        // The list of valid characters is defined by an internal-only spec.
245        let rp = RequestPairs::new()
246            .with_pair(("allowed-symbols", "!#$&'*+-.^_`|~"))
247            .with_pair(("allowed-digits", "01234567890"))
248            .with_pair((
249                "allowed-characters",
250                "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
251            ))
252            .with_pair(("allowed-whitespace", " \t"));
253        let _header_value: HeaderValue = rp
254            .try_into()
255            .expect("request pairs can be converted into valid header value.");
256    }
257}