|
| 1 | +//! Helpers for diagnosing AWS SDK errors. |
| 2 | +//! |
| 3 | +//! `SdkError`'s `Display` impl collapses everything below the SDK |
| 4 | +//! (DNS, TCP, TLS, connector pool, credential providers) into a single |
| 5 | +//! short string like `"dispatch failure"`, which makes prod logs nearly |
| 6 | +//! useless for distinguishing root causes. |
| 7 | +//! |
| 8 | +//! This module provides two utilities meant to be paired at every AWS SDK |
| 9 | +//! call-site: |
| 10 | +//! |
| 11 | +//! * [`classify_sdk_error`] — returns a stable, low-cardinality `&'static str` |
| 12 | +//! suitable for a `tracing` field or metric label, distinguishing the |
| 13 | +//! actionable subcategories of `DispatchFailure` (timeout / io / user / |
| 14 | +//! other) from `TimeoutError`, `ServiceError`, etc. |
| 15 | +//! * [`DisplayErrorContext`] — re-export of the SDK's own helper that walks |
| 16 | +//! the full `std::error::Error::source()` chain so the underlying cause |
| 17 | +//! (e.g., `connect timed out`, `dns error: failed to lookup address`) |
| 18 | +//! appears in the log instead of just the top-level wrapper. |
| 19 | +//! |
| 20 | +//! Typical usage: |
| 21 | +//! |
| 22 | +//! ```ignore |
| 23 | +//! tracing::error!( |
| 24 | +//! error.kind = classify_sdk_error(&err), |
| 25 | +//! error.detail = %DisplayErrorContext(&err), |
| 26 | +//! "AWS call failed" |
| 27 | +//! ); |
| 28 | +//! ``` |
| 29 | +
|
| 30 | +pub use aws_smithy_types::error::display::DisplayErrorContext; |
| 31 | + |
| 32 | +use aws_smithy_runtime_api::client::result::SdkError; |
| 33 | + |
| 34 | +/// Classify an [`SdkError`] into a stable, low-cardinality kind tag. |
| 35 | +/// |
| 36 | +/// `DispatchFailure` is split by its underlying [`ConnectorError`] kind so |
| 37 | +/// log aggregators can distinguish a `dispatch_timeout` (likely runtime |
| 38 | +/// starvation or a slow upstream) from a `dispatch_io` (connection reset, |
| 39 | +/// pool exhaustion) without parsing free-form strings. |
| 40 | +/// |
| 41 | +/// [`ConnectorError`]: aws_smithy_runtime_api::client::result::ConnectorError |
| 42 | +pub fn classify_sdk_error<E, R>(err: &SdkError<E, R>) -> &'static str { |
| 43 | + match err { |
| 44 | + SdkError::ConstructionFailure(_) => "construction", |
| 45 | + SdkError::TimeoutError(_) => "timeout", |
| 46 | + SdkError::DispatchFailure(inner) => match inner.as_connector_error() { |
| 47 | + Some(ce) if ce.is_timeout() => "dispatch_timeout", |
| 48 | + Some(ce) if ce.is_io() => "dispatch_io", |
| 49 | + Some(ce) if ce.is_user() => "dispatch_user", |
| 50 | + Some(_) => "dispatch_other", |
| 51 | + None => "dispatch_unknown", |
| 52 | + }, |
| 53 | + SdkError::ResponseError(_) => "response_parse", |
| 54 | + SdkError::ServiceError(_) => "service", |
| 55 | + // SdkError is `#[non_exhaustive]`; future variants get a stable label. |
| 56 | + _ => "unknown", |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +#[cfg(test)] |
| 61 | +mod tests { |
| 62 | + use super::*; |
| 63 | + use aws_smithy_runtime_api::client::orchestrator::HttpResponse; |
| 64 | + use aws_smithy_runtime_api::client::result::ConnectorError; |
| 65 | + use std::convert::Infallible; |
| 66 | + use std::io; |
| 67 | + |
| 68 | + // SdkError<E, R> — E is the operation error type, R is the response type. |
| 69 | + // ConstructionFailure / TimeoutError / DispatchFailure don't actually carry |
| 70 | + // an E, so Infallible is the cheapest stand-in. R is supplied explicitly |
| 71 | + // because aws-smithy-runtime-api 1.11+ no longer defaults it. |
| 72 | + type TestErr = SdkError<Infallible, HttpResponse>; |
| 73 | + |
| 74 | + fn boxed(msg: &str) -> Box<dyn std::error::Error + Send + Sync> { |
| 75 | + Box::new(io::Error::other(msg.to_string())) |
| 76 | + } |
| 77 | + |
| 78 | + #[test] |
| 79 | + fn classifies_construction_failure() { |
| 80 | + let err: TestErr = SdkError::construction_failure(boxed("could not build request")); |
| 81 | + assert_eq!(classify_sdk_error(&err), "construction"); |
| 82 | + } |
| 83 | + |
| 84 | + #[test] |
| 85 | + fn classifies_timeout_error() { |
| 86 | + let err: TestErr = SdkError::timeout_error(boxed("operation timed out")); |
| 87 | + assert_eq!(classify_sdk_error(&err), "timeout"); |
| 88 | + } |
| 89 | + |
| 90 | + #[test] |
| 91 | + fn classifies_dispatch_failure_timeout() { |
| 92 | + // Connector-level timeout is the most likely shape under runtime |
| 93 | + // saturation; this kind tag is what should drive the "we're starving |
| 94 | + // the AWS SDK connector futures" diagnosis. |
| 95 | + let err: TestErr = |
| 96 | + SdkError::dispatch_failure(ConnectorError::timeout(boxed("connect timed out"))); |
| 97 | + assert_eq!(classify_sdk_error(&err), "dispatch_timeout"); |
| 98 | + } |
| 99 | + |
| 100 | + #[test] |
| 101 | + fn classifies_dispatch_failure_io() { |
| 102 | + let err: TestErr = |
| 103 | + SdkError::dispatch_failure(ConnectorError::io(boxed("connection reset by peer"))); |
| 104 | + assert_eq!(classify_sdk_error(&err), "dispatch_io"); |
| 105 | + } |
| 106 | + |
| 107 | + #[test] |
| 108 | + fn classifies_dispatch_failure_user() { |
| 109 | + let err: TestErr = |
| 110 | + SdkError::dispatch_failure(ConnectorError::user(boxed("invalid endpoint URL"))); |
| 111 | + assert_eq!(classify_sdk_error(&err), "dispatch_user"); |
| 112 | + } |
| 113 | + |
| 114 | + #[test] |
| 115 | + fn classifies_dispatch_failure_other() { |
| 116 | + let err: TestErr = |
| 117 | + SdkError::dispatch_failure(ConnectorError::other(boxed("unexpected"), None)); |
| 118 | + assert_eq!(classify_sdk_error(&err), "dispatch_other"); |
| 119 | + } |
| 120 | + |
| 121 | + #[test] |
| 122 | + fn display_error_context_surfaces_underlying_cause() { |
| 123 | + // Re-pins the behaviour the helper relies on: DisplayErrorContext must |
| 124 | + // walk source() chains, otherwise the prod logs would still collapse to |
| 125 | + // "dispatch failure" and we'd be back where we started. |
| 126 | + let inner = io::Error::new(io::ErrorKind::TimedOut, "tcp connect timed out at layer 4"); |
| 127 | + let err: TestErr = SdkError::dispatch_failure(ConnectorError::timeout(Box::new(inner))); |
| 128 | + let rendered = format!("{}", DisplayErrorContext(&err)); |
| 129 | + assert!( |
| 130 | + rendered.contains("tcp connect timed out at layer 4"), |
| 131 | + "DisplayErrorContext should expose the inner cause; got: {rendered}" |
| 132 | + ); |
| 133 | + } |
| 134 | +} |
0 commit comments