Skip to main content

aws_smithy_runtime/client/
defaults.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! Runtime plugins that provide defaults for clients.
7//!
8//! Note: these are the absolute base-level defaults. They may not be the defaults
9//! for _your_ client, since many things can change these defaults on the way to
10//! code generating and constructing a full client.
11
12use crate::client::http::body::content_length_enforcement::EnforceContentLengthRuntimePlugin;
13use crate::client::identity::IdentityCache;
14use crate::client::retries::strategy::standard::TokenBucketProvider;
15use crate::client::retries::strategy::StandardRetryStrategy;
16use crate::client::retries::RetryPartition;
17use aws_smithy_async::rt::sleep::default_async_sleep;
18use aws_smithy_async::time::SystemTimeSource;
19use aws_smithy_runtime_api::box_error::BoxError;
20use aws_smithy_runtime_api::client::behavior_version::BehaviorVersion;
21use aws_smithy_runtime_api::client::http::SharedHttpClient;
22use aws_smithy_runtime_api::client::interceptors::SharedInterceptor;
23use aws_smithy_runtime_api::client::runtime_components::{
24    RuntimeComponentsBuilder, SharedConfigValidator,
25};
26use aws_smithy_runtime_api::client::runtime_plugin::{
27    Order, SharedRuntimePlugin, StaticRuntimePlugin,
28};
29use aws_smithy_runtime_api::client::stalled_stream_protection::StalledStreamProtectionConfig;
30use aws_smithy_runtime_api::shared::IntoShared;
31use aws_smithy_types::config_bag::{ConfigBag, FrozenLayer, Layer};
32use aws_smithy_types::retry::RetryConfig;
33use aws_smithy_types::timeout::TimeoutConfig;
34use std::borrow::Cow;
35use std::time::Duration;
36
37/// Default connect timeout for all clients with BehaviorVersion >= v2026_01_12
38const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_millis(3100);
39
40fn default_plugin<CompFn>(name: &'static str, components_fn: CompFn) -> StaticRuntimePlugin
41where
42    CompFn: FnOnce(RuntimeComponentsBuilder) -> RuntimeComponentsBuilder,
43{
44    StaticRuntimePlugin::new()
45        .with_order(Order::Defaults)
46        .with_runtime_components((components_fn)(RuntimeComponentsBuilder::new(name)))
47}
48
49fn layer<LayerFn>(name: &'static str, layer_fn: LayerFn) -> FrozenLayer
50where
51    LayerFn: FnOnce(&mut Layer),
52{
53    let mut layer = Layer::new(name);
54    (layer_fn)(&mut layer);
55    layer.freeze()
56}
57
58/// Runtime plugin that provides a default connector.
59#[deprecated(
60    since = "1.8.0",
61    note = "This function wasn't intended to be public, and didn't take the behavior major version as an argument, so it couldn't be evolved over time."
62)]
63pub fn default_http_client_plugin() -> Option<SharedRuntimePlugin> {
64    #[expect(deprecated)]
65    default_http_client_plugin_v2(BehaviorVersion::v2024_03_28())
66}
67
68/// Runtime plugin that provides a default HTTPS connector.
69pub fn default_http_client_plugin_v2(
70    behavior_version: BehaviorVersion,
71) -> Option<SharedRuntimePlugin> {
72    let mut _default: Option<SharedHttpClient> = None;
73
74    #[allow(deprecated)]
75    if behavior_version.is_at_least(BehaviorVersion::v2026_01_12()) {
76        // the latest https stack takes precedence if the config flag
77        // is enabled otherwise try to fall back to the legacy connector
78        // if that feature flag is available.
79        #[cfg(all(
80            feature = "connector-hyper-0-14-x",
81            not(feature = "default-https-client")
82        ))]
83        #[allow(deprecated)]
84        {
85            _default = crate::client::http::hyper_014::default_client();
86        }
87
88        // takes precedence over legacy connector if enabled
89        #[cfg(feature = "default-https-client")]
90        {
91            let opts = crate::client::http::DefaultClientOptions::default()
92                .with_behavior_version(behavior_version);
93            _default = crate::client::http::default_https_client(opts);
94        }
95    } else {
96        // fallback to legacy hyper client for given behavior version
97        #[cfg(feature = "connector-hyper-0-14-x")]
98        #[allow(deprecated)]
99        {
100            _default = crate::client::http::hyper_014::default_client();
101        }
102    }
103
104    _default.map(|default| {
105        default_plugin("default_http_client_plugin", |components| {
106            components.with_http_client(Some(default))
107        })
108        .into_shared()
109    })
110}
111
112/// Runtime plugin that provides a default async sleep implementation.
113pub fn default_sleep_impl_plugin() -> Option<SharedRuntimePlugin> {
114    default_async_sleep().map(|default| {
115        default_plugin("default_sleep_impl_plugin", |components| {
116            components.with_sleep_impl(Some(default))
117        })
118        .into_shared()
119    })
120}
121
122/// Runtime plugin that provides a default time source.
123pub fn default_time_source_plugin() -> Option<SharedRuntimePlugin> {
124    Some(
125        default_plugin("default_time_source_plugin", |components| {
126            components.with_time_source(Some(SystemTimeSource::new()))
127        })
128        .into_shared(),
129    )
130}
131
132/// Runtime plugin that sets the default retry strategy, config (disabled), and partition.
133pub fn default_retry_config_plugin(
134    default_partition_name: impl Into<Cow<'static, str>>,
135) -> Option<SharedRuntimePlugin> {
136    let retry_partition = RetryPartition::new(default_partition_name);
137    Some(
138        default_plugin("default_retry_config_plugin", |components| {
139            components
140                .with_retry_strategy(Some(StandardRetryStrategy::new()))
141                .with_config_validator(SharedConfigValidator::base_client_config_fn(
142                    validate_retry_config,
143                ))
144                // TODO(retry 2.1 on by default): revert TokenBucketProvider to the old
145                // approach: `new()` takes `init: impl FnOnce() -> TokenBucket`, eagerly
146                // calls `TOKEN_BUCKET.get_or_init(default_partition.clone(), init)`, stores
147                // the result directly (no OnceLock), and the hot path is just `.clone()`.
148                .with_interceptor(SharedInterceptor::permanent(TokenBucketProvider::new(
149                    retry_partition.clone(),
150                )))
151        })
152        .with_config(layer("default_retry_config", |layer| {
153            layer.store_put(RetryConfig::disabled());
154            layer.store_put(retry_partition);
155        }))
156        .into_shared(),
157    )
158}
159
160/// Runtime plugin that sets the default retry strategy, config, and partition.
161///
162/// This version respects the behavior version to enable retries by default for newer versions.
163/// For AWS SDK clients with BehaviorVersion >= v2026_01_12, retries are enabled by default.
164pub fn default_retry_config_plugin_v2(params: &DefaultPluginParams) -> Option<SharedRuntimePlugin> {
165    let retry_partition = RetryPartition::new(
166        params
167            .retry_partition_name
168            .as_ref()
169            .expect("retry partition name is required")
170            .clone(),
171    );
172    let is_aws_sdk = params.is_aws_sdk;
173    let behavior_version = params
174        .behavior_version
175        .unwrap_or_else(BehaviorVersion::latest);
176    Some(
177        default_plugin("default_retry_config_plugin", |components| {
178            components
179                .with_retry_strategy(Some(StandardRetryStrategy::new()))
180                .with_config_validator(SharedConfigValidator::base_client_config_fn(
181                    validate_retry_config,
182                ))
183                .with_interceptor(SharedInterceptor::permanent(TokenBucketProvider::new(
184                    retry_partition.clone(),
185                )))
186        })
187        .with_config(layer("default_retry_config", |layer| {
188            #[allow(deprecated)]
189            let retry_config =
190                if is_aws_sdk && behavior_version.is_at_least(BehaviorVersion::v2026_01_12()) {
191                    RetryConfig::standard()
192                } else {
193                    RetryConfig::disabled()
194                };
195            layer.store_put(retry_config);
196            layer.store_put(retry_partition);
197        }))
198        .into_shared(),
199    )
200}
201
202fn validate_retry_config(
203    components: &RuntimeComponentsBuilder,
204    cfg: &ConfigBag,
205) -> Result<(), BoxError> {
206    if let Some(retry_config) = cfg.load::<RetryConfig>() {
207        if retry_config.has_retry() && components.sleep_impl().is_none() {
208            Err("An async sleep implementation is required for retry to work. Please provide a `sleep_impl` on \
209                 the config, or disable timeouts.".into())
210        } else {
211            Ok(())
212        }
213    } else {
214        Err(
215            "The default retry config was removed, and no other config was put in its place."
216                .into(),
217        )
218    }
219}
220
221/// Runtime plugin that sets the default timeout config (no timeouts).
222pub fn default_timeout_config_plugin() -> Option<SharedRuntimePlugin> {
223    Some(
224        default_plugin("default_timeout_config_plugin", |components| {
225            components.with_config_validator(SharedConfigValidator::base_client_config_fn(
226                validate_timeout_config,
227            ))
228        })
229        .with_config(layer("default_timeout_config", |layer| {
230            layer.store_put(TimeoutConfig::disabled());
231        }))
232        .into_shared(),
233    )
234}
235
236/// Runtime plugin that sets the default timeout config.
237///
238/// This version respects the behavior version to enable connection timeout by default for newer versions.
239/// For all clients with BehaviorVersion >= v2026_01_12, a 3.1s connection timeout is set.
240pub fn default_timeout_config_plugin_v2(
241    params: &DefaultPluginParams,
242) -> Option<SharedRuntimePlugin> {
243    let behavior_version = params
244        .behavior_version
245        .unwrap_or_else(BehaviorVersion::latest);
246    Some(
247        default_plugin("default_timeout_config_plugin", |components| {
248            components.with_config_validator(SharedConfigValidator::base_client_config_fn(
249                validate_timeout_config,
250            ))
251        })
252        .with_config(layer("default_timeout_config", |layer| {
253            #[allow(deprecated)]
254            let timeout_config = if behavior_version.is_at_least(BehaviorVersion::v2026_01_12()) {
255                // All clients with BMV >= v2026_01_12: Set connect_timeout only
256                TimeoutConfig::builder()
257                    .connect_timeout(DEFAULT_CONNECT_TIMEOUT)
258                    .build()
259            } else {
260                // Old behavior versions: All timeouts disabled
261                TimeoutConfig::disabled()
262            };
263            layer.store_put(timeout_config);
264        }))
265        .into_shared(),
266    )
267}
268
269fn validate_timeout_config(
270    components: &RuntimeComponentsBuilder,
271    cfg: &ConfigBag,
272) -> Result<(), BoxError> {
273    if let Some(timeout_config) = cfg.load::<TimeoutConfig>() {
274        if timeout_config.has_timeouts() && components.sleep_impl().is_none() {
275            Err("An async sleep implementation is required for timeouts to work. Please provide a `sleep_impl` on \
276                 the config, or disable timeouts.".into())
277        } else {
278            Ok(())
279        }
280    } else {
281        Err(
282            "The default timeout config was removed, and no other config was put in its place."
283                .into(),
284        )
285    }
286}
287
288/// Runtime plugin that registers the default identity cache implementation.
289pub fn default_identity_cache_plugin() -> Option<SharedRuntimePlugin> {
290    Some(
291        default_plugin("default_identity_cache_plugin", |components| {
292            components.with_identity_cache(Some(IdentityCache::lazy().build()))
293        })
294        .into_shared(),
295    )
296}
297
298/// Runtime plugin that sets the default stalled stream protection config.
299///
300/// By default, when throughput falls below 1/Bs for more than 5 seconds, the
301/// stream is cancelled.
302#[deprecated(
303    since = "1.2.0",
304    note = "This function wasn't intended to be public, and didn't take the behavior major version as an argument, so it couldn't be evolved over time."
305)]
306pub fn default_stalled_stream_protection_config_plugin() -> Option<SharedRuntimePlugin> {
307    #[expect(deprecated)]
308    default_stalled_stream_protection_config_plugin_v2(BehaviorVersion::v2023_11_09())
309}
310fn default_stalled_stream_protection_config_plugin_v2(
311    behavior_version: BehaviorVersion,
312) -> Option<SharedRuntimePlugin> {
313    Some(
314        default_plugin(
315            "default_stalled_stream_protection_config_plugin",
316            |components| {
317                components.with_config_validator(SharedConfigValidator::base_client_config_fn(
318                    validate_stalled_stream_protection_config,
319                ))
320            },
321        )
322        .with_config(layer("default_stalled_stream_protection_config", |layer| {
323            let mut config =
324                StalledStreamProtectionConfig::enabled().grace_period(Duration::from_secs(5));
325            // Before v2024_03_28, upload streams did not have stalled stream protection by default
326            #[expect(deprecated)]
327            if !behavior_version.is_at_least(BehaviorVersion::v2024_03_28()) {
328                config = config.upload_enabled(false);
329            }
330            layer.store_put(config.build());
331        }))
332        .into_shared(),
333    )
334}
335
336fn enforce_content_length_runtime_plugin() -> Option<SharedRuntimePlugin> {
337    Some(EnforceContentLengthRuntimePlugin::new().into_shared())
338}
339
340fn validate_stalled_stream_protection_config(
341    components: &RuntimeComponentsBuilder,
342    cfg: &ConfigBag,
343) -> Result<(), BoxError> {
344    if let Some(stalled_stream_protection_config) = cfg.load::<StalledStreamProtectionConfig>() {
345        if stalled_stream_protection_config.is_enabled() {
346            if components.sleep_impl().is_none() {
347                return Err(
348                    "An async sleep implementation is required for stalled stream protection to work. \
349                     Please provide a `sleep_impl` on the config, or disable stalled stream protection.".into());
350            }
351
352            if components.time_source().is_none() {
353                return Err(
354                    "A time source is required for stalled stream protection to work.\
355                     Please provide a `time_source` on the config, or disable stalled stream protection.".into());
356            }
357        }
358
359        Ok(())
360    } else {
361        Err(
362            "The default stalled stream protection config was removed, and no other config was put in its place."
363                .into(),
364        )
365    }
366}
367
368/// Arguments for the [`default_plugins`] method.
369///
370/// This is a struct to enable adding new parameters in the future without breaking the API.
371#[non_exhaustive]
372#[derive(Debug, Default)]
373pub struct DefaultPluginParams {
374    retry_partition_name: Option<Cow<'static, str>>,
375    behavior_version: Option<BehaviorVersion>,
376    is_aws_sdk: bool,
377}
378
379impl DefaultPluginParams {
380    /// Creates a new [`DefaultPluginParams`].
381    pub fn new() -> Self {
382        Default::default()
383    }
384
385    /// Sets the retry partition name.
386    pub fn with_retry_partition_name(mut self, name: impl Into<Cow<'static, str>>) -> Self {
387        self.retry_partition_name = Some(name.into());
388        self
389    }
390
391    /// Sets the behavior major version.
392    pub fn with_behavior_version(mut self, version: BehaviorVersion) -> Self {
393        self.behavior_version = Some(version);
394        self
395    }
396
397    /// Marks this as an AWS SDK client (enables retries by default for newer behavior versions).
398    pub fn with_is_aws_sdk(mut self, is_aws_sdk: bool) -> Self {
399        self.is_aws_sdk = is_aws_sdk;
400        self
401    }
402}
403
404/// All default plugins.
405pub fn default_plugins(
406    params: DefaultPluginParams,
407) -> impl IntoIterator<Item = SharedRuntimePlugin> {
408    let behavior_version = params
409        .behavior_version
410        .unwrap_or_else(BehaviorVersion::latest);
411
412    [
413        default_http_client_plugin_v2(behavior_version),
414        default_identity_cache_plugin(),
415        default_retry_config_plugin_v2(&params),
416        default_sleep_impl_plugin(),
417        default_time_source_plugin(),
418        default_timeout_config_plugin_v2(&params),
419        enforce_content_length_runtime_plugin(),
420        default_stalled_stream_protection_config_plugin_v2(behavior_version),
421    ]
422    .into_iter()
423    .flatten()
424    .collect::<Vec<SharedRuntimePlugin>>()
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use aws_smithy_runtime_api::client::runtime_plugin::{RuntimePlugin, RuntimePlugins};
431
432    fn test_plugin_params(version: BehaviorVersion) -> DefaultPluginParams {
433        DefaultPluginParams::new()
434            .with_behavior_version(version)
435            .with_retry_partition_name("dontcare")
436            .with_is_aws_sdk(false) // Default to non-AWS SDK for existing tests
437    }
438    fn config_for(plugins: impl IntoIterator<Item = SharedRuntimePlugin>) -> ConfigBag {
439        let mut config = ConfigBag::base();
440        let plugins = RuntimePlugins::new().with_client_plugins(plugins);
441        plugins.apply_client_configuration(&mut config).unwrap();
442        config
443    }
444
445    #[test]
446    #[expect(deprecated)]
447    fn v2024_03_28_stalled_stream_protection_difference() {
448        let latest = config_for(default_plugins(test_plugin_params(
449            BehaviorVersion::latest(),
450        )));
451        let v2023 = config_for(default_plugins(test_plugin_params(
452            BehaviorVersion::v2023_11_09(),
453        )));
454
455        assert!(
456            latest
457                .load::<StalledStreamProtectionConfig>()
458                .unwrap()
459                .upload_enabled(),
460            "stalled stream protection on uploads MUST be enabled after v2024_03_28"
461        );
462        assert!(
463            !v2023
464                .load::<StalledStreamProtectionConfig>()
465                .unwrap()
466                .upload_enabled(),
467            "stalled stream protection on uploads MUST NOT be enabled before v2024_03_28"
468        );
469    }
470
471    #[test]
472    fn test_retry_enabled_for_aws_sdk() {
473        let params = DefaultPluginParams::new()
474            .with_retry_partition_name("test-partition")
475            .with_behavior_version(BehaviorVersion::latest())
476            .with_is_aws_sdk(true);
477        let plugin = default_retry_config_plugin_v2(&params).expect("plugin should be created");
478
479        let config = plugin.config().expect("config should exist");
480        let retry_config = config
481            .load::<RetryConfig>()
482            .expect("retry config should exist");
483
484        assert_eq!(
485            retry_config.max_attempts(),
486            3,
487            "retries should be enabled with max_attempts=3 for AWS SDK with latest behavior version"
488        );
489    }
490
491    #[test]
492    #[expect(deprecated)]
493    fn test_retry_disabled_for_aws_sdk_old_behavior_version() {
494        // Any version before v2026_01_12 should have retries disabled
495        let params = DefaultPluginParams::new()
496            .with_retry_partition_name("test-partition")
497            .with_behavior_version(BehaviorVersion::v2024_03_28())
498            .with_is_aws_sdk(true);
499        let plugin = default_retry_config_plugin_v2(&params).expect("plugin should be created");
500
501        let config = plugin.config().expect("config should exist");
502        let retry_config = config
503            .load::<RetryConfig>()
504            .expect("retry config should exist");
505
506        assert_eq!(
507            retry_config.max_attempts(),
508            1,
509            "retries should be disabled for AWS SDK with behavior version < v2026_01_12"
510        );
511    }
512
513    #[test]
514    #[allow(deprecated)]
515    fn test_retry_enabled_at_cutoff_version() {
516        // v2026_01_12 is the cutoff - retries should be enabled from this version onwards
517        let params = DefaultPluginParams::new()
518            .with_retry_partition_name("test-partition")
519            .with_behavior_version(BehaviorVersion::v2026_01_12())
520            .with_is_aws_sdk(true);
521        let plugin = default_retry_config_plugin_v2(&params).expect("plugin should be created");
522
523        let config = plugin.config().expect("config should exist");
524        let retry_config = config
525            .load::<RetryConfig>()
526            .expect("retry config should exist");
527
528        assert_eq!(
529            retry_config.max_attempts(),
530            3,
531            "retries should be enabled for AWS SDK starting from v2026_01_12"
532        );
533    }
534
535    #[test]
536    fn test_retry_disabled_for_non_aws_sdk() {
537        let params = DefaultPluginParams::new()
538            .with_retry_partition_name("test-partition")
539            .with_behavior_version(BehaviorVersion::latest())
540            .with_is_aws_sdk(false);
541        let plugin = default_retry_config_plugin_v2(&params).expect("plugin should be created");
542
543        let config = plugin.config().expect("config should exist");
544        let retry_config = config
545            .load::<RetryConfig>()
546            .expect("retry config should exist");
547
548        assert_eq!(
549            retry_config.max_attempts(),
550            1,
551            "retries should be disabled for non-AWS SDK clients"
552        );
553    }
554
555    #[test]
556    #[expect(deprecated)]
557    fn test_behavior_version_gates_retry_for_aws_sdk() {
558        // This test demonstrates the complete behavior:
559        // AWS SDK clients get retries enabled ONLY when BehaviorVersion >= v2026_01_12
560
561        // Test all behavior versions
562        let test_cases = vec![
563            (BehaviorVersion::v2023_11_09(), 1, "v2023_11_09 (old)"),
564            (BehaviorVersion::v2024_03_28(), 1, "v2024_03_28 (old)"),
565            (BehaviorVersion::v2025_01_17(), 1, "v2025_01_17 (old)"),
566            (BehaviorVersion::v2025_08_07(), 1, "v2025_08_07 (old)"),
567            (BehaviorVersion::v2026_01_12(), 3, "v2026_01_12 (cutoff)"),
568            (BehaviorVersion::latest(), 3, "latest"),
569        ];
570
571        for (version, expected_attempts, version_name) in test_cases {
572            let params = DefaultPluginParams::new()
573                .with_retry_partition_name("test-partition")
574                .with_behavior_version(version)
575                .with_is_aws_sdk(true);
576
577            let plugin = default_retry_config_plugin_v2(&params).expect("plugin should be created");
578            let config = plugin.config().expect("config should exist");
579            let retry_config = config
580                .load::<RetryConfig>()
581                .expect("retry config should exist");
582
583            assert_eq!(
584                retry_config.max_attempts(),
585                expected_attempts,
586                "AWS SDK with {} should have {} max attempts",
587                version_name,
588                expected_attempts
589            );
590        }
591    }
592
593    #[test]
594    #[expect(deprecated)]
595    fn test_complete_default_plugins_integration() {
596        // This test simulates the complete flow as it would happen in a real AWS SDK client
597        // It verifies that default_plugins() correctly applies retry config based on
598        // both is_aws_sdk flag and BehaviorVersion
599
600        // Scenario 1: AWS SDK with latest behavior version -> retries enabled
601        let params_aws_latest = DefaultPluginParams::new()
602            .with_retry_partition_name("aws-s3")
603            .with_behavior_version(BehaviorVersion::latest())
604            .with_is_aws_sdk(true);
605
606        let config_aws_latest = config_for(default_plugins(params_aws_latest));
607        let retry_aws_latest = config_aws_latest
608            .load::<RetryConfig>()
609            .expect("retry config should exist");
610        assert_eq!(
611            retry_aws_latest.max_attempts(),
612            3,
613            "AWS SDK with latest behavior version should have retries enabled (3 attempts)"
614        );
615
616        // Scenario 2: AWS SDK with old behavior version -> retries disabled
617        let params_aws_old = DefaultPluginParams::new()
618            .with_retry_partition_name("aws-s3")
619            .with_behavior_version(BehaviorVersion::v2024_03_28())
620            .with_is_aws_sdk(true);
621
622        let config_aws_old = config_for(default_plugins(params_aws_old));
623        let retry_aws_old = config_aws_old
624            .load::<RetryConfig>()
625            .expect("retry config should exist");
626        assert_eq!(
627            retry_aws_old.max_attempts(),
628            1,
629            "AWS SDK with old behavior version should have retries disabled (1 attempt)"
630        );
631
632        // Scenario 3: Non-AWS SDK (generic Smithy client) -> retries always disabled
633        let params_generic = DefaultPluginParams::new()
634            .with_retry_partition_name("my-service")
635            .with_behavior_version(BehaviorVersion::latest())
636            .with_is_aws_sdk(false);
637
638        let config_generic = config_for(default_plugins(params_generic));
639        let retry_generic = config_generic
640            .load::<RetryConfig>()
641            .expect("retry config should exist");
642        assert_eq!(
643            retry_generic.max_attempts(),
644            1,
645            "Non-AWS SDK clients should always have retries disabled (1 attempt)"
646        );
647
648        // Scenario 4: Verify the cutoff version v2026_01_12 is the exact boundary
649        let params_cutoff = DefaultPluginParams::new()
650            .with_retry_partition_name("aws-s3")
651            .with_behavior_version(BehaviorVersion::v2026_01_12())
652            .with_is_aws_sdk(true);
653
654        let config_cutoff = config_for(default_plugins(params_cutoff));
655        let retry_cutoff = config_cutoff
656            .load::<RetryConfig>()
657            .expect("retry config should exist");
658        assert_eq!(
659            retry_cutoff.max_attempts(),
660            3,
661            "AWS SDK with v2026_01_12 (the cutoff version) should have retries enabled (3 attempts)"
662        );
663    }
664}