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
38pub(crate) const 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/// Announce the upcoming default HTTP client change, while the legacy stack is still the default.
69///
70/// Called only where the legacy client was actually selected, which is exactly the set of
71/// configurations that will resolve differently once `rustls` stops being a default feature of
72/// generated SDK crates. Callers who have already pinned `legacy-https-client` cannot be
73/// distinguished from callers riding the default at this layer — both arrive as `tls-rustls` — so
74/// they see it too; the message is written to be actionable either way.
75///
76/// This is transient: delete it when that default change lands. From then on the fallback warning
77/// in `default_http_client_plugin_v2` is what callers see instead.
78#[cfg(feature = "connector-hyper-0-14-x")]
79fn warn_legacy_client_default_is_changing(behavior_version: BehaviorVersion) {
80    let emit = || {
81        tracing::warn!(
82            behavior_version = ?behavior_version,
83            "this build resolves to the legacy hyper 0.14.x / http 0.2.x HTTP client. In the 2.x \
84             release, currently expected November 2026, the default becomes the hyper 1.x client: \
85             a different TLS implementation, with different connection-pooling and timeout \
86             behavior. To keep the legacy client, add `features = [\"legacy-https-client\"]` to \
87             your AWS SDK crate now — that spelling is stable across the change, whereas `rustls` \
88             will become a synonym for the hyper 1.x client. To move early instead, use \
89             `BehaviorVersion::v2026_01_12()` or later. \
90             See https://github.com/smithy-lang/smithy-rs/issues/4489",
91        );
92    };
93
94    // Once per process, so building a client per request does not flood the log. Under `cfg(test)`
95    // every call warns, because a process-wide latch would let whichever test ran first consume
96    // the only warning and make the others silently vacuous.
97    #[cfg(not(test))]
98    {
99        static WARNED: std::sync::Once = std::sync::Once::new();
100        WARNED.call_once(emit);
101    }
102    #[cfg(test)]
103    emit();
104}
105
106/// Runtime plugin that provides a default HTTPS connector.
107pub fn default_http_client_plugin_v2(
108    behavior_version: BehaviorVersion,
109) -> Option<SharedRuntimePlugin> {
110    let mut _default: Option<SharedHttpClient> = None;
111
112    #[allow(deprecated)]
113    if behavior_version.is_at_least(BehaviorVersion::v2026_01_12()) {
114        // the latest https stack takes precedence if the config flag
115        // is enabled otherwise try to fall back to the legacy connector
116        // if that feature flag is available.
117        #[cfg(all(
118            feature = "connector-hyper-0-14-x",
119            not(feature = "default-https-client")
120        ))]
121        #[allow(deprecated)]
122        {
123            _default = crate::client::http::hyper_014::default_client();
124
125            // A legacy-only build reaches the legacy client even on a current behavior version,
126            // and will resolve to hyper 1.x once `rustls` stops being a default feature.
127            if _default.is_some() {
128                warn_legacy_client_default_is_changing(behavior_version);
129            }
130        }
131
132        // takes precedence over legacy connector if enabled
133        #[cfg(feature = "default-https-client")]
134        {
135            let opts = crate::client::http::DefaultClientOptions::default()
136                .with_behavior_version(behavior_version);
137            _default = crate::client::http::default_https_client(opts);
138        }
139    } else {
140        // fallback to legacy hyper client for given behavior version
141        #[cfg(feature = "connector-hyper-0-14-x")]
142        #[allow(deprecated)]
143        {
144            _default = crate::client::http::hyper_014::default_client();
145
146            // The main population for the upcoming default change: an older behavior version with
147            // the legacy stack compiled in, which is what a default build is today.
148            if _default.is_some() {
149                warn_legacy_client_default_is_changing(behavior_version);
150            }
151        }
152
153        // Fall back to the latest https stack so that an older behavior version still gets a
154        // working HTTP client rather than none at all. The legacy connector comes back empty both
155        // when it isn't compiled in and when it is compiled in without a TLS implementation
156        // (`hyper_014::default_client` requires `legacy-rustls-ring`), so key off the value rather
157        // than off `connector-hyper-0-14-x`.
158        //
159        // NOTE: this deliberately only runs when the legacy client came back empty, so builds that
160        // do have one keep getting it for these behavior versions, exactly as before.
161        #[cfg(feature = "default-https-client")]
162        if _default.is_none() {
163            let opts = crate::client::http::DefaultClientOptions::default()
164                .with_behavior_version(behavior_version);
165            _default = crate::client::http::default_https_client(opts);
166
167            // Say so rather than substituting a different HTTP stack silently: the behavior
168            // version asked for the legacy one, and a caller who pinned it for a hyper 0.14.x
169            // quirk needs to know they are not getting it.
170            if _default.is_some() {
171                tracing::warn!(
172                    behavior_version = ?behavior_version,
173                    "this behavior version selects the legacy hyper 0.14.x HTTP client, which is \
174                     not available in this build, so the default hyper 1.x HTTPS client is being \
175                     used instead. Enable the `legacy-https-client` feature on your AWS SDK crate \
176                     (or `aws-smithy-runtime/tls-rustls`) to get the legacy stack, or move to \
177                     `BehaviorVersion::v2026_01_12()` or later to stop seeing this warning.",
178                );
179            }
180        }
181    }
182
183    _default.map(|default| {
184        default_plugin("default_http_client_plugin", |components| {
185            components.with_http_client(Some(default))
186        })
187        .into_shared()
188    })
189}
190
191/// Runtime plugin that provides a default async sleep implementation.
192pub fn default_sleep_impl_plugin() -> Option<SharedRuntimePlugin> {
193    default_async_sleep().map(|default| {
194        default_plugin("default_sleep_impl_plugin", |components| {
195            components.with_sleep_impl(Some(default))
196        })
197        .into_shared()
198    })
199}
200
201/// Runtime plugin that provides a default time source.
202pub fn default_time_source_plugin() -> Option<SharedRuntimePlugin> {
203    Some(
204        default_plugin("default_time_source_plugin", |components| {
205            components.with_time_source(Some(SystemTimeSource::new()))
206        })
207        .into_shared(),
208    )
209}
210
211/// Runtime plugin that sets the default retry strategy, config (disabled), and partition.
212pub fn default_retry_config_plugin(
213    default_partition_name: impl Into<Cow<'static, str>>,
214) -> Option<SharedRuntimePlugin> {
215    let retry_partition = RetryPartition::new(default_partition_name);
216    Some(
217        default_plugin("default_retry_config_plugin", |components| {
218            components
219                .with_retry_strategy(Some(StandardRetryStrategy::new()))
220                .with_config_validator(SharedConfigValidator::base_client_config_fn(
221                    validate_retry_config,
222                ))
223                // TODO(retry 2.1 on by default): revert TokenBucketProvider to the old
224                // approach: `new()` takes `init: impl FnOnce() -> TokenBucket`, eagerly
225                // calls `TOKEN_BUCKET.get_or_init(default_partition.clone(), init)`, stores
226                // the result directly (no OnceLock), and the hot path is just `.clone()`.
227                .with_interceptor(SharedInterceptor::permanent(TokenBucketProvider::new(
228                    retry_partition.clone(),
229                )))
230        })
231        .with_config(layer("default_retry_config", |layer| {
232            layer.store_put(RetryConfig::disabled());
233            layer.store_put(retry_partition);
234        }))
235        .into_shared(),
236    )
237}
238
239/// Runtime plugin that sets the default retry strategy, config, and partition.
240///
241/// This version respects the behavior version to enable retries by default for newer versions.
242/// For AWS SDK clients with BehaviorVersion >= v2026_01_12, retries are enabled by default.
243pub fn default_retry_config_plugin_v2(params: &DefaultPluginParams) -> Option<SharedRuntimePlugin> {
244    let retry_partition = RetryPartition::new(
245        params
246            .retry_partition_name
247            .as_ref()
248            .expect("retry partition name is required")
249            .clone(),
250    );
251    let is_aws_sdk = params.is_aws_sdk;
252    let behavior_version = params
253        .behavior_version
254        .unwrap_or_else(BehaviorVersion::latest);
255    Some(
256        default_plugin("default_retry_config_plugin", |components| {
257            components
258                .with_retry_strategy(Some(StandardRetryStrategy::new()))
259                .with_config_validator(SharedConfigValidator::base_client_config_fn(
260                    validate_retry_config,
261                ))
262                .with_interceptor(SharedInterceptor::permanent(TokenBucketProvider::new(
263                    retry_partition.clone(),
264                )))
265        })
266        .with_config(layer("default_retry_config", |layer| {
267            #[allow(deprecated)]
268            let retry_config =
269                if is_aws_sdk && behavior_version.is_at_least(BehaviorVersion::v2026_01_12()) {
270                    RetryConfig::standard()
271                } else {
272                    RetryConfig::disabled()
273                };
274            layer.store_put(retry_config);
275            layer.store_put(retry_partition);
276        }))
277        .into_shared(),
278    )
279}
280
281fn validate_retry_config(
282    components: &RuntimeComponentsBuilder,
283    cfg: &ConfigBag,
284) -> Result<(), BoxError> {
285    if let Some(retry_config) = cfg.load::<RetryConfig>() {
286        if retry_config.has_retry() && components.sleep_impl().is_none() {
287            Err("An async sleep implementation is required for retry to work. Please provide a `sleep_impl` on \
288                 the config, or disable timeouts.".into())
289        } else {
290            Ok(())
291        }
292    } else {
293        Err(
294            "The default retry config was removed, and no other config was put in its place."
295                .into(),
296        )
297    }
298}
299
300/// Runtime plugin that sets the default timeout config (no timeouts).
301pub fn default_timeout_config_plugin() -> Option<SharedRuntimePlugin> {
302    Some(
303        default_plugin("default_timeout_config_plugin", |components| {
304            components.with_config_validator(SharedConfigValidator::base_client_config_fn(
305                validate_timeout_config,
306            ))
307        })
308        .with_config(layer("default_timeout_config", |layer| {
309            layer.store_put(TimeoutConfig::disabled());
310        }))
311        .into_shared(),
312    )
313}
314
315/// Runtime plugin that sets the default timeout config.
316///
317/// This version respects the behavior version to enable connection timeout by default for newer versions.
318/// For all clients with BehaviorVersion >= v2026_01_12, a 3.1s connection timeout is set.
319pub fn default_timeout_config_plugin_v2(
320    params: &DefaultPluginParams,
321) -> Option<SharedRuntimePlugin> {
322    let behavior_version = params
323        .behavior_version
324        .unwrap_or_else(BehaviorVersion::latest);
325    Some(
326        default_plugin("default_timeout_config_plugin", |components| {
327            components.with_config_validator(SharedConfigValidator::base_client_config_fn(
328                validate_timeout_config,
329            ))
330        })
331        .with_config(layer("default_timeout_config", |layer| {
332            #[allow(deprecated)]
333            let timeout_config = if behavior_version.is_at_least(BehaviorVersion::v2026_01_12()) {
334                // All clients with BMV >= v2026_01_12: Set connect_timeout only
335                TimeoutConfig::builder()
336                    .connect_timeout(DEFAULT_CONNECT_TIMEOUT)
337                    .build()
338            } else {
339                // Old behavior versions: All timeouts disabled
340                TimeoutConfig::disabled()
341            };
342            layer.store_put(timeout_config);
343        }))
344        .into_shared(),
345    )
346}
347
348fn validate_timeout_config(
349    components: &RuntimeComponentsBuilder,
350    cfg: &ConfigBag,
351) -> Result<(), BoxError> {
352    if let Some(timeout_config) = cfg.load::<TimeoutConfig>() {
353        if timeout_config.has_timeouts() && components.sleep_impl().is_none() {
354            Err("An async sleep implementation is required for timeouts to work. Please provide a `sleep_impl` on \
355                 the config, or disable timeouts.".into())
356        } else {
357            Ok(())
358        }
359    } else {
360        Err(
361            "The default timeout config was removed, and no other config was put in its place."
362                .into(),
363        )
364    }
365}
366
367/// Runtime plugin that registers the default identity cache implementation.
368pub fn default_identity_cache_plugin() -> Option<SharedRuntimePlugin> {
369    Some(
370        default_plugin("default_identity_cache_plugin", |components| {
371            components.with_identity_cache(Some(IdentityCache::lazy().build()))
372        })
373        .into_shared(),
374    )
375}
376
377/// Runtime plugin that sets the default stalled stream protection config.
378///
379/// By default, when throughput falls below 1/Bs for more than 5 seconds, the
380/// stream is cancelled.
381#[deprecated(
382    since = "1.2.0",
383    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."
384)]
385pub fn default_stalled_stream_protection_config_plugin() -> Option<SharedRuntimePlugin> {
386    #[expect(deprecated)]
387    default_stalled_stream_protection_config_plugin_v2(BehaviorVersion::v2023_11_09())
388}
389fn default_stalled_stream_protection_config_plugin_v2(
390    behavior_version: BehaviorVersion,
391) -> Option<SharedRuntimePlugin> {
392    Some(
393        default_plugin(
394            "default_stalled_stream_protection_config_plugin",
395            |components| {
396                components.with_config_validator(SharedConfigValidator::base_client_config_fn(
397                    validate_stalled_stream_protection_config,
398                ))
399            },
400        )
401        .with_config(layer("default_stalled_stream_protection_config", |layer| {
402            let mut config =
403                StalledStreamProtectionConfig::enabled().grace_period(Duration::from_secs(5));
404            // Before v2024_03_28, upload streams did not have stalled stream protection by default
405            #[expect(deprecated)]
406            if !behavior_version.is_at_least(BehaviorVersion::v2024_03_28()) {
407                config = config.upload_enabled(false);
408            }
409            layer.store_put(config.build());
410        }))
411        .into_shared(),
412    )
413}
414
415fn enforce_content_length_runtime_plugin() -> Option<SharedRuntimePlugin> {
416    Some(EnforceContentLengthRuntimePlugin::new().into_shared())
417}
418
419fn validate_stalled_stream_protection_config(
420    components: &RuntimeComponentsBuilder,
421    cfg: &ConfigBag,
422) -> Result<(), BoxError> {
423    if let Some(stalled_stream_protection_config) = cfg.load::<StalledStreamProtectionConfig>() {
424        if stalled_stream_protection_config.is_enabled() {
425            if components.sleep_impl().is_none() {
426                return Err(
427                    "An async sleep implementation is required for stalled stream protection to work. \
428                     Please provide a `sleep_impl` on the config, or disable stalled stream protection.".into());
429            }
430
431            if components.time_source().is_none() {
432                return Err(
433                    "A time source is required for stalled stream protection to work.\
434                     Please provide a `time_source` on the config, or disable stalled stream protection.".into());
435            }
436        }
437
438        Ok(())
439    } else {
440        Err(
441            "The default stalled stream protection config was removed, and no other config was put in its place."
442                .into(),
443        )
444    }
445}
446
447/// Arguments for the [`default_plugins`] method.
448///
449/// This is a struct to enable adding new parameters in the future without breaking the API.
450#[non_exhaustive]
451#[derive(Debug, Default)]
452pub struct DefaultPluginParams {
453    retry_partition_name: Option<Cow<'static, str>>,
454    behavior_version: Option<BehaviorVersion>,
455    is_aws_sdk: bool,
456}
457
458impl DefaultPluginParams {
459    /// Creates a new [`DefaultPluginParams`].
460    pub fn new() -> Self {
461        Default::default()
462    }
463
464    /// Sets the retry partition name.
465    pub fn with_retry_partition_name(mut self, name: impl Into<Cow<'static, str>>) -> Self {
466        self.retry_partition_name = Some(name.into());
467        self
468    }
469
470    /// Sets the behavior major version.
471    pub fn with_behavior_version(mut self, version: BehaviorVersion) -> Self {
472        self.behavior_version = Some(version);
473        self
474    }
475
476    /// Marks this as an AWS SDK client (enables retries by default for newer behavior versions).
477    pub fn with_is_aws_sdk(mut self, is_aws_sdk: bool) -> Self {
478        self.is_aws_sdk = is_aws_sdk;
479        self
480    }
481}
482
483/// All default plugins.
484pub fn default_plugins(
485    params: DefaultPluginParams,
486) -> impl IntoIterator<Item = SharedRuntimePlugin> {
487    let behavior_version = params
488        .behavior_version
489        .unwrap_or_else(BehaviorVersion::latest);
490
491    [
492        default_http_client_plugin_v2(behavior_version),
493        default_identity_cache_plugin(),
494        default_retry_config_plugin_v2(&params),
495        default_sleep_impl_plugin(),
496        default_time_source_plugin(),
497        default_timeout_config_plugin_v2(&params),
498        enforce_content_length_runtime_plugin(),
499        default_stalled_stream_protection_config_plugin_v2(behavior_version),
500    ]
501    .into_iter()
502    .flatten()
503    .collect::<Vec<SharedRuntimePlugin>>()
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use aws_smithy_runtime_api::client::runtime_plugin::{RuntimePlugin, RuntimePlugins};
510    #[cfg(any(feature = "default-https-client", feature = "tls-rustls"))]
511    use tracing_test::traced_test;
512
513    fn test_plugin_params(version: BehaviorVersion) -> DefaultPluginParams {
514        DefaultPluginParams::new()
515            .with_behavior_version(version)
516            .with_retry_partition_name("dontcare")
517            .with_is_aws_sdk(false) // Default to non-AWS SDK for existing tests
518    }
519    fn config_for(plugins: impl IntoIterator<Item = SharedRuntimePlugin>) -> ConfigBag {
520        let mut config = ConfigBag::base();
521        let plugins = RuntimePlugins::new().with_client_plugins(plugins);
522        plugins.apply_client_configuration(&mut config).unwrap();
523        config
524    }
525
526    #[test]
527    #[expect(deprecated)]
528    fn v2024_03_28_stalled_stream_protection_difference() {
529        let latest = config_for(default_plugins(test_plugin_params(
530            BehaviorVersion::latest(),
531        )));
532        let v2023 = config_for(default_plugins(test_plugin_params(
533            BehaviorVersion::v2023_11_09(),
534        )));
535
536        assert!(
537            latest
538                .load::<StalledStreamProtectionConfig>()
539                .unwrap()
540                .upload_enabled(),
541            "stalled stream protection on uploads MUST be enabled after v2024_03_28"
542        );
543        assert!(
544            !v2023
545                .load::<StalledStreamProtectionConfig>()
546                .unwrap()
547                .upload_enabled(),
548            "stalled stream protection on uploads MUST NOT be enabled before v2024_03_28"
549        );
550    }
551
552    #[test]
553    fn test_retry_enabled_for_aws_sdk() {
554        let params = DefaultPluginParams::new()
555            .with_retry_partition_name("test-partition")
556            .with_behavior_version(BehaviorVersion::latest())
557            .with_is_aws_sdk(true);
558        let plugin = default_retry_config_plugin_v2(&params).expect("plugin should be created");
559
560        let config = plugin.config().expect("config should exist");
561        let retry_config = config
562            .load::<RetryConfig>()
563            .expect("retry config should exist");
564
565        assert_eq!(
566            retry_config.max_attempts(),
567            3,
568            "retries should be enabled with max_attempts=3 for AWS SDK with latest behavior version"
569        );
570    }
571
572    #[test]
573    #[expect(deprecated)]
574    fn test_retry_disabled_for_aws_sdk_old_behavior_version() {
575        // Any version before v2026_01_12 should have retries disabled
576        let params = DefaultPluginParams::new()
577            .with_retry_partition_name("test-partition")
578            .with_behavior_version(BehaviorVersion::v2024_03_28())
579            .with_is_aws_sdk(true);
580        let plugin = default_retry_config_plugin_v2(&params).expect("plugin should be created");
581
582        let config = plugin.config().expect("config should exist");
583        let retry_config = config
584            .load::<RetryConfig>()
585            .expect("retry config should exist");
586
587        assert_eq!(
588            retry_config.max_attempts(),
589            1,
590            "retries should be disabled for AWS SDK with behavior version < v2026_01_12"
591        );
592    }
593
594    #[test]
595    #[allow(deprecated)]
596    fn test_retry_enabled_at_cutoff_version() {
597        // v2026_01_12 is the cutoff - retries should be enabled from this version onwards
598        let params = DefaultPluginParams::new()
599            .with_retry_partition_name("test-partition")
600            .with_behavior_version(BehaviorVersion::v2026_01_12())
601            .with_is_aws_sdk(true);
602        let plugin = default_retry_config_plugin_v2(&params).expect("plugin should be created");
603
604        let config = plugin.config().expect("config should exist");
605        let retry_config = config
606            .load::<RetryConfig>()
607            .expect("retry config should exist");
608
609        assert_eq!(
610            retry_config.max_attempts(),
611            3,
612            "retries should be enabled for AWS SDK starting from v2026_01_12"
613        );
614    }
615
616    #[test]
617    fn test_retry_disabled_for_non_aws_sdk() {
618        let params = DefaultPluginParams::new()
619            .with_retry_partition_name("test-partition")
620            .with_behavior_version(BehaviorVersion::latest())
621            .with_is_aws_sdk(false);
622        let plugin = default_retry_config_plugin_v2(&params).expect("plugin should be created");
623
624        let config = plugin.config().expect("config should exist");
625        let retry_config = config
626            .load::<RetryConfig>()
627            .expect("retry config should exist");
628
629        assert_eq!(
630            retry_config.max_attempts(),
631            1,
632            "retries should be disabled for non-AWS SDK clients"
633        );
634    }
635
636    #[test]
637    #[expect(deprecated)]
638    fn test_behavior_version_gates_retry_for_aws_sdk() {
639        // This test demonstrates the complete behavior:
640        // AWS SDK clients get retries enabled ONLY when BehaviorVersion >= v2026_01_12
641
642        // Test all behavior versions
643        let test_cases = vec![
644            (BehaviorVersion::v2023_11_09(), 1, "v2023_11_09 (old)"),
645            (BehaviorVersion::v2024_03_28(), 1, "v2024_03_28 (old)"),
646            (BehaviorVersion::v2025_01_17(), 1, "v2025_01_17 (old)"),
647            (BehaviorVersion::v2025_08_07(), 1, "v2025_08_07 (old)"),
648            (BehaviorVersion::v2026_01_12(), 3, "v2026_01_12 (cutoff)"),
649            (BehaviorVersion::latest(), 3, "latest"),
650        ];
651
652        for (version, expected_attempts, version_name) in test_cases {
653            let params = DefaultPluginParams::new()
654                .with_retry_partition_name("test-partition")
655                .with_behavior_version(version)
656                .with_is_aws_sdk(true);
657
658            let plugin = default_retry_config_plugin_v2(&params).expect("plugin should be created");
659            let config = plugin.config().expect("config should exist");
660            let retry_config = config
661                .load::<RetryConfig>()
662                .expect("retry config should exist");
663
664            assert_eq!(
665                retry_config.max_attempts(),
666                expected_attempts,
667                "AWS SDK with {} should have {} max attempts",
668                version_name,
669                expected_attempts
670            );
671        }
672    }
673
674    #[test]
675    #[expect(deprecated)]
676    fn test_complete_default_plugins_integration() {
677        // This test simulates the complete flow as it would happen in a real AWS SDK client
678        // It verifies that default_plugins() correctly applies retry config based on
679        // both is_aws_sdk flag and BehaviorVersion
680
681        // Scenario 1: AWS SDK with latest behavior version -> retries enabled
682        let params_aws_latest = DefaultPluginParams::new()
683            .with_retry_partition_name("aws-s3")
684            .with_behavior_version(BehaviorVersion::latest())
685            .with_is_aws_sdk(true);
686
687        let config_aws_latest = config_for(default_plugins(params_aws_latest));
688        let retry_aws_latest = config_aws_latest
689            .load::<RetryConfig>()
690            .expect("retry config should exist");
691        assert_eq!(
692            retry_aws_latest.max_attempts(),
693            3,
694            "AWS SDK with latest behavior version should have retries enabled (3 attempts)"
695        );
696
697        // Scenario 2: AWS SDK with old behavior version -> retries disabled
698        let params_aws_old = DefaultPluginParams::new()
699            .with_retry_partition_name("aws-s3")
700            .with_behavior_version(BehaviorVersion::v2024_03_28())
701            .with_is_aws_sdk(true);
702
703        let config_aws_old = config_for(default_plugins(params_aws_old));
704        let retry_aws_old = config_aws_old
705            .load::<RetryConfig>()
706            .expect("retry config should exist");
707        assert_eq!(
708            retry_aws_old.max_attempts(),
709            1,
710            "AWS SDK with old behavior version should have retries disabled (1 attempt)"
711        );
712
713        // Scenario 3: Non-AWS SDK (generic Smithy client) -> retries always disabled
714        let params_generic = DefaultPluginParams::new()
715            .with_retry_partition_name("my-service")
716            .with_behavior_version(BehaviorVersion::latest())
717            .with_is_aws_sdk(false);
718
719        let config_generic = config_for(default_plugins(params_generic));
720        let retry_generic = config_generic
721            .load::<RetryConfig>()
722            .expect("retry config should exist");
723        assert_eq!(
724            retry_generic.max_attempts(),
725            1,
726            "Non-AWS SDK clients should always have retries disabled (1 attempt)"
727        );
728
729        // Scenario 4: Verify the cutoff version v2026_01_12 is the exact boundary
730        let params_cutoff = DefaultPluginParams::new()
731            .with_retry_partition_name("aws-s3")
732            .with_behavior_version(BehaviorVersion::v2026_01_12())
733            .with_is_aws_sdk(true);
734
735        let config_cutoff = config_for(default_plugins(params_cutoff));
736        let retry_cutoff = config_cutoff
737            .load::<RetryConfig>()
738            .expect("retry config should exist");
739        assert_eq!(
740            retry_cutoff.max_attempts(),
741            3,
742            "AWS SDK with v2026_01_12 (the cutoff version) should have retries enabled (3 attempts)"
743        );
744    }
745
746    /// A behavior version older than `v2026_01_12` must still end up with an HTTP client whenever
747    /// the hyper 1.x stack is compiled in, rather than with none at all.
748    ///
749    /// The configuration that actually exercises the fallback is `connector-hyper-0-14-x` plus
750    /// `default-https-client` with no legacy TLS implementation: `hyper_014::default_client()`
751    /// returns `None` there, so the fallback is the only thing that can supply a client. Note that
752    /// `--all-features` does *not* exercise it, because `tls-rustls` gives the legacy connector a
753    /// TLS implementation and it returns `Some`, which would satisfy the assertion below no matter
754    /// what the fallback did. `tools/ci-scripts/check-rust-runtimes` runs that combination
755    /// explicitly so this test has teeth.
756    #[test]
757    #[expect(deprecated)]
758    fn old_behavior_version_still_gets_an_http_client() {
759        let old = default_http_client_plugin_v2(BehaviorVersion::v2024_03_28());
760        let latest = default_http_client_plugin_v2(BehaviorVersion::latest());
761
762        // The hyper 1.x stack is available, so both behavior versions get a client: the latest
763        // directly, and the older one either from a working legacy connector or from the fallback.
764        #[cfg(feature = "default-https-client")]
765        {
766            assert!(
767                old.is_some(),
768                "a pre-v2026_01_12 behavior version must fall back to the hyper 1.x client \
769                 instead of getting no HTTP client"
770            );
771            assert!(
772                latest.is_some(),
773                "the latest behavior version must get the hyper 1.x client"
774            );
775        }
776
777        // No hyper 1.x stack, so there is nothing to fall back to and the legacy connector is the
778        // only possible source. It yields a client only when it also has a TLS implementation.
779        #[cfg(all(not(feature = "default-https-client"), feature = "tls-rustls"))]
780        assert!(
781            old.is_some(),
782            "a pre-v2026_01_12 behavior version must still get the legacy client when that is \
783             the only stack compiled in"
784        );
785
786        // Neither stack is compiled in, so no default client is possible for either version.
787        #[cfg(all(
788            not(feature = "default-https-client"),
789            not(feature = "connector-hyper-0-14-x")
790        ))]
791        {
792            assert!(
793                old.is_none(),
794                "no HTTP client stack is compiled in, so there is nothing to install"
795            );
796            assert!(
797                latest.is_none(),
798                "no HTTP client stack is compiled in, so there is nothing to install"
799            );
800        }
801
802        let _ = (old, latest);
803    }
804
805    /// Falling back must not be silent: a caller who pinned an old behavior version for a
806    /// hyper 0.14.x quirk needs to learn that they are on the hyper 1.x client instead.
807    ///
808    /// Gated to the configurations where the fallback actually runs. With `tls-rustls` the legacy
809    /// connector has a TLS implementation and returns `Some`, so no fallback happens and there is
810    /// correctly nothing to warn about.
811    #[test]
812    #[traced_test]
813    #[expect(deprecated)]
814    #[cfg(all(feature = "default-https-client", not(feature = "tls-rustls")))]
815    fn falling_back_to_the_hyper_1x_client_warns() {
816        let old = default_http_client_plugin_v2(BehaviorVersion::v2024_03_28());
817        assert!(old.is_some(), "the fallback should have supplied a client");
818        assert!(
819            logs_contain("selects the legacy hyper 0.14.x HTTP client"),
820            "falling back to the hyper 1.x client must be logged"
821        );
822    }
823
824    /// While the legacy stack is still the default, a build that resolves to it must be told the
825    /// default is changing — otherwise the change arrives with no notice.
826    ///
827    /// Gated to a build where the legacy client actually yields something: `hyper_014::default_client`
828    /// needs `legacy-rustls-ring`, which `tls-rustls` supplies.
829    #[test]
830    #[traced_test]
831    #[expect(deprecated)]
832    #[cfg(feature = "tls-rustls")]
833    fn resolving_to_the_legacy_client_warns_that_the_default_is_changing() {
834        let old = default_http_client_plugin_v2(BehaviorVersion::v2024_03_28());
835        assert!(old.is_some(), "the legacy client should have been selected");
836        assert!(
837            logs_contain("the default becomes the hyper 1.x client"),
838            "resolving to the legacy client must announce the upcoming default change"
839        );
840        assert!(
841            logs_contain("legacy-https-client"),
842            "the warning must name the feature that pins the legacy client"
843        );
844    }
845
846    /// The warning is about *resolving to* the legacy client, so a build without it must stay quiet
847    /// rather than warn about a stack it never had.
848    #[test]
849    #[traced_test]
850    #[expect(deprecated)]
851    #[cfg(all(
852        feature = "default-https-client",
853        not(feature = "connector-hyper-0-14-x")
854    ))]
855    fn a_build_without_the_legacy_client_does_not_warn_about_it() {
856        let _ = default_http_client_plugin_v2(BehaviorVersion::v2024_03_28());
857        assert!(
858            !logs_contain("the default becomes the hyper 1.x client"),
859            "a build with no legacy client must not warn about the legacy default changing"
860        );
861    }
862
863    /// The converse: a behavior version that asks for the current stack has nothing to warn about.
864    #[test]
865    #[traced_test]
866    #[cfg(feature = "default-https-client")]
867    fn the_latest_behavior_version_does_not_warn() {
868        let latest = default_http_client_plugin_v2(BehaviorVersion::latest());
869        assert!(latest.is_some(), "the latest version should get a client");
870        assert!(
871            !logs_contain("selects the legacy hyper 0.14.x HTTP client"),
872            "the latest behavior version must not warn about the legacy stack"
873        );
874    }
875}