Skip to content

Commit b6762e6

Browse files
committed
chore: Log and expose aws services error internal details
1 parent 8194752 commit b6762e6

6 files changed

Lines changed: 190 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ panic = "unwind"
2525
aws-config = { version = "1.6.3", features = ["behavior-version-latest"] }
2626
aws-sdk-kms = "1.91.0"
2727
aws-sdk-sqs = "1.56.0"
28+
aws-smithy-types = "1"
29+
aws-smithy-runtime-api = "1"
2830
actix-web = "4"
2931
tracing = "0.1"
3032
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }

src/queues/sqs/backend.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use crate::{
2121
},
2222
models::{DefaultAppState, NetworkType},
2323
queues::QueueBackendType,
24+
utils::{classify_sdk_error, DisplayErrorContext},
2425
};
2526
use actix_web::web::ThinData;
2627

@@ -444,8 +445,13 @@ impl SqsBackend {
444445
}
445446

446447
let response = request.send().await.map_err(|e| {
447-
error!(error = %e, queue_url = %queue_url, "Failed to send message to SQS");
448-
QueueBackendError::SqsError(format!("SendMessage failed: {e}"))
448+
error!(
449+
error.kind = classify_sdk_error(&e),
450+
error.detail = %DisplayErrorContext(&e),
451+
queue_url = %queue_url,
452+
"Failed to send message to SQS"
453+
);
454+
QueueBackendError::SqsError(format!("SendMessage failed: {}", DisplayErrorContext(&e)))
449455
})?;
450456

451457
let message_id = response

src/services/aws_kms/mod.rs

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,11 @@ use crate::{
4949
client_cache::AsyncClientCache, signer::evm::utils::recover_evm_signature_from_der,
5050
},
5151
utils::{
52-
self, derive_ethereum_address_from_der, derive_solana_address_from_der,
53-
derive_stellar_address_from_der,
52+
self, classify_sdk_error, derive_ethereum_address_from_der, derive_solana_address_from_der,
53+
derive_stellar_address_from_der, DisplayErrorContext,
5454
},
5555
};
56-
use tracing::debug;
56+
use tracing::{debug, warn};
5757

5858
#[cfg(test)]
5959
use mockall::{automock, mock};
@@ -280,8 +280,16 @@ impl AwsKmsK256 for AwsKmsClient {
280280
.send()
281281
.await
282282
.map_err(|e| {
283+
warn!(
284+
error.kind = classify_sdk_error(&e),
285+
error.detail = %DisplayErrorContext(&e),
286+
kms_key_id = %key_id,
287+
operation = "get_public_key_secp256k1",
288+
"AWS KMS get_public_key failed"
289+
);
283290
AwsKmsError::GetError(format!(
284-
"Failed to get secp256k1 public key for key '{key_id}': {e:?}"
291+
"Failed to get secp256k1 public key for key '{key_id}': {}",
292+
DisplayErrorContext(&e)
285293
))
286294
})?;
287295

@@ -316,7 +324,16 @@ impl AwsKmsK256 for AwsKmsClient {
316324

317325
// Process the result, extract DER signature
318326
let der_signature = sign_result
319-
.map_err(|e| AwsKmsError::PermissionError(e.to_string()))?
327+
.map_err(|e| {
328+
warn!(
329+
error.kind = classify_sdk_error(&e),
330+
error.detail = %DisplayErrorContext(&e),
331+
kms_key_id = %key_id,
332+
operation = "sign_digest_secp256k1",
333+
"AWS KMS sign failed"
334+
);
335+
AwsKmsError::PermissionError(DisplayErrorContext(&e).to_string())
336+
})?
320337
.signature
321338
.ok_or(AwsKmsError::SignError(
322339
"Signature not found in response".to_string(),
@@ -347,8 +364,16 @@ impl AwsKmsEd25519 for AwsKmsClient {
347364
.send()
348365
.await
349366
.map_err(|e| {
367+
warn!(
368+
error.kind = classify_sdk_error(&e),
369+
error.detail = %DisplayErrorContext(&e),
370+
kms_key_id = %key_id,
371+
operation = "get_public_key_ed25519",
372+
"AWS KMS get_public_key failed"
373+
);
350374
AwsKmsError::GetError(format!(
351-
"Failed to get Ed25519 public key for key '{key_id}': {e:?}"
375+
"Failed to get Ed25519 public key for key '{key_id}': {}",
376+
DisplayErrorContext(&e)
352377
))
353378
})?;
354379

@@ -386,7 +411,16 @@ impl AwsKmsEd25519 for AwsKmsClient {
386411

387412
// Process the result, extract signature
388413
let signature = sign_result
389-
.map_err(|e| AwsKmsError::SignError(e.to_string()))?
414+
.map_err(|e| {
415+
warn!(
416+
error.kind = classify_sdk_error(&e),
417+
error.detail = %DisplayErrorContext(&e),
418+
kms_key_id = %key_id,
419+
operation = "sign_ed25519",
420+
"AWS KMS sign failed"
421+
);
422+
AwsKmsError::SignError(DisplayErrorContext(&e).to_string())
423+
})?
390424
.signature
391425
.ok_or(AwsKmsError::SignError(
392426
"Signature not found in response".to_string(),

src/utils/aws_error.rs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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+
}

src/utils/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ pub use url_security::*;
5555
mod error_sanitization;
5656
pub use error_sanitization::*;
5757

58+
mod aws_error;
59+
pub use aws_error::*;
60+
5861
mod url;
5962
pub use url::*;
6063

0 commit comments

Comments
 (0)