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