Skip to main content

dynamo_runtime/
error.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Dynamo's shared semantic error contract.
5//!
6//! A [`DynamoError`] describes **what failed**. It deliberately does not prescribe an HTTP status, retry action, stream action, or worker-health decision. Those decisions belong to the consumer that owns the relevant local state, such as response commitment, retry budget, replayability, deadline, and worker observations.
7//!
8//! The component closest to the cause classifies a failure once. Callers should propagate an existing [`DynamoError`] directly; internal transports preserve its semantic identity, and protocol boundaries render it for their clients. Generic conversion from an error chain recovers a typed non-internal semantic source, but it intentionally treats an unclassified or internal source as `Internal/runtime.unclassified`. See [DEP #14354](https://github.com/ai-dynamo/dynamo/issues/14354) for the architecture and rollout rationale.
9//!
10//! # Error identity
11//!
12//! Every error has two identity fields:
13//!
14//! - [`ErrorClass`] is the coarse, closed category used for exhaustive consumer policy. New producers should select a canonical class such as [`ErrorClass::InvalidRequest`] or [`ErrorClass::Unavailable`]. The older transport-specific variants are retained for compatibility and normalize through [`DynamoError::class`].
15//! - [`ErrorReason`] is a stable, bounded catalog key that identifies the specific cause. A reason belongs to exactly one normalized class. It is safe for low-cardinality policy and metric dimensions; arbitrary exception text and user input are not.
16//!
17//! The builder and deserializer validate the class/reason pair. An unknown, malformed, or mismatched identity fails closed to `Internal/runtime.invalid_error`, and its public details are discarded. Consumers should therefore use [`DynamoError::class`], [`DynamoError::reason`], and [`DynamoError::public_details`] instead of reading the public fields directly. [`DynamoError::error_type`] exists for legacy policy only.
18//!
19//! ## Choosing a canonical class
20//!
21//! | Class | Use when |
22//! |---|---|
23//! | [`ErrorClass::InvalidRequest`] | The caller supplied malformed input or failed request-level validation. |
24//! | [`ErrorClass::Unauthenticated`] | Authentication credentials are missing or invalid. |
25//! | [`ErrorClass::PermissionDenied`] | The authenticated caller is not allowed to perform the operation. |
26//! | [`ErrorClass::NotFound`] | A caller-visible resource does not exist. |
27//! | [`ErrorClass::Conflict`] | The request conflicts with current resource state. |
28//! | [`ErrorClass::PayloadTooLarge`] | The request exceeds an advertised size limit. |
29//! | [`ErrorClass::UnsupportedMedia`] | The request uses an unsupported media type. |
30//! | [`ErrorClass::RateLimited`] | A caller-specific admission or rate limit was exceeded. |
31//! | [`ErrorClass::CapacityExhausted`] | Dynamo, a selected worker, or the eligible worker pool lacks capacity; the reason distinguishes worker overload from pool exhaustion. |
32//! | [`ErrorClass::Cancelled`] | The request was cancelled, usually because the client disconnected. |
33//! | [`ErrorClass::Unavailable`] | A service, worker, pool, or Dynamo-owned dependency is unavailable. |
34//! | [`ErrorClass::BackendProtocol`] | A backend response violates the expected protocol. |
35//! | [`ErrorClass::DeadlineExceeded`] | A request or attempt deadline expired. |
36//! | [`ErrorClass::NotImplemented`] | A valid requested capability is unsupported. |
37//! | [`ErrorClass::Internal`] | A defect, invalid classification, or otherwise unclassified failure occurred. |
38//!
39//! Similar symptoms do not necessarily have the same class. For example, malformed client input is [`ErrorClass::InvalidRequest`], invalid operator configuration is [`ErrorClass::Internal`], caller-specific throttling is [`ErrorClass::RateLimited`], and pool-wide capacity pressure is [`ErrorClass::CapacityExhausted`].
40//!
41//! # Data visibility
42//!
43//! [`Diagnostic`] contains bounded operator-facing context. It may be used in logs and traces, but it must never be copied into a client response or metric label. Do not place secrets, credentials, prompts, generated text, or raw upstream response bodies in a diagnostic.
44//!
45//! [`PublicDetails`] is the only occurrence-specific channel available for client rendering, but the type does not sanitize its values. Producers must populate it only at a boundary that knows the value is safe. A [`PublicDetails::Message`] must contain fixed, cataloged, or explicitly allowlisted text; it must not contain user input, prompts, generated content, secrets, URLs, diagnostics, or raw upstream responses. Never derive public details from a diagnostic or arbitrary backend exception text.
46//!
47//! # Producer guidance
48//!
49//! 1. Classify at the boundary that understands the cause instead of relying on message parsing farther downstream.
50//! 2. Select the canonical class for the failure's meaning, independent of the current transport.
51//! 3. Use a registered reason that belongs to that class. Add new reasons to the catalog with class-consistency tests rather than emitting dynamic strings.
52//! 4. Add a diagnostic only when it improves operator debugging, and add public details only when they are explicitly client-safe.
53//! 5. Propagate an existing [`DynamoError`] directly whenever possible. If a generic wrapper is unavoidable, retain the typed error as its source and remember that generic conversion recovers only non-internal semantic classifications.
54//!
55//! [`DynamoError::msg`], conversion from an unclassified [`std::error::Error`], and conversion from a generic wrapper whose typed source is internal intentionally produce `Internal/runtime.unclassified`; use them as a fallback, not for failures whose meaning is known.
56//!
57//! ```rust,no_run
58//! use dynamo_runtime::error::{DynamoError, ErrorClass, ErrorReason};
59//!
60//! let error = DynamoError::builder()
61//!     .class(ErrorClass::InvalidRequest)
62//!     .reason(ErrorReason::new("request.invalid").expect("registered reason"))
63//!     .diagnostic("messages[2].role failed validation")
64//!     .public_message("The message role is invalid")
65//!     .build();
66//!
67//! assert_eq!(error.class(), ErrorClass::InvalidRequest);
68//! assert_eq!(error.reason().as_str(), "request.invalid");
69//! assert_eq!(error.public_message(), Some("The message role is invalid"));
70//! ```
71//!
72//! Structured details are preferable when the protocol renderer needs machine-readable values:
73//!
74//! ```rust,no_run
75//! use dynamo_runtime::error::{DynamoError, ErrorClass, ErrorReason, PublicDetails};
76//!
77//! let error = DynamoError::builder()
78//!     .class(ErrorClass::PayloadTooLarge)
79//!     .reason(ErrorReason::new("request.payload_too_large").expect("registered reason"))
80//!     .public_details(PublicDetails::SizeLimit {
81//!         limit: 1_048_576,
82//!         actual: Some(1_250_000),
83//!     })
84//!     .build();
85//! ```
86//!
87//! # Consumer guidance
88//!
89//! Consumers apply their own policy to the validated semantic identity:
90//!
91//! - Protocol renderers map [`DynamoError::class`] to a status or legal terminal stream event and expose only approved [`PublicDetails`].
92//! - Retry and migration code owns its reason policy and combines it with locally owned budget, deadline, routing, replay, and continuation state.
93//! - Worker-health code combines the reason with local worker and pool observations rather than inferring health from an HTTP status.
94//! - Metrics use the normalized class and registered reason. Diagnostics, request identifiers, exception types, URLs, and user-controlled values must not become labels.
95//!
96//! A recovered internal attempt is not a final client failure. Terminal failure accounting belongs at the frontend rendering boundary so one propagated error is not counted by every subsystem it crosses.
97
98use serde::{Deserialize, Serialize};
99use std::fmt;
100use std::sync::{Arc, LazyLock};
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
103pub enum ErrorClass {
104    /// The request contains invalid input (e.g., prompt exceeds context length).
105    InvalidArgument,
106    /// Failed to establish a connection to a remote worker.
107    CannotConnect,
108    /// An established connection was lost unexpectedly.
109    Disconnected,
110    /// A connection or request timed out.
111    ConnectionTimeout,
112    /// The backend accepted the request but stopped responding (stream inactivity timeout).
113    ResponseTimeout,
114    /// The request was cancelled (e.g., client disconnected).
115    Cancelled,
116    /// The capacity constraint cannot be relieved by selecting another worker.
117    /// This most commonly means the whole eligible worker pool is exhausted.
118    ResourceExhausted,
119    /// One selected worker is out of capacity while others may still have room.
120    /// Distinct from [`Self::ResourceExhausted`] so a request whose routing
121    /// constraints permit reassignment can migrate; both surface as HTTP 529.
122    WorkerOverloaded,
123    /// No backend worker is currently available to handle the request.
124    Unavailable,
125    /// One addressed worker answered that it does not serve this request's
126    /// endpoint instance (stale discovery or a worker shutting down) while
127    /// other workers may. Distinct from [`Self::Unavailable`] so the request
128    /// can migrate; both surface as HTTP 503.
129    WorkerUnavailable,
130    /// Error originating from a backend engine.
131    Backend(BackendError),
132    /// The client request is malformed or fails request-level validation.
133    InvalidRequest,
134    /// Authentication credentials are missing or invalid.
135    Unauthenticated,
136    /// The authenticated caller is not permitted to perform the operation.
137    PermissionDenied,
138    /// The requested resource does not exist.
139    NotFound,
140    /// The request conflicts with current resource state.
141    Conflict,
142    /// The request body exceeds a configured size limit.
143    PayloadTooLarge,
144    /// The request uses an unsupported media type.
145    UnsupportedMedia,
146    /// The caller exceeded an admission or request-rate limit.
147    RateLimited,
148    /// The eligible worker pool has no capacity.
149    CapacityExhausted,
150    /// A backend response violates the expected protocol.
151    BackendProtocol,
152    /// The operation exceeded its deadline.
153    DeadlineExceeded,
154    /// The requested operation is not implemented.
155    NotImplemented,
156    /// An internal defect or unclassified failure occurred.
157    Internal,
158    /// Uncategorized or unknown error.
159    Unknown,
160}
161
162#[derive(Deserialize)]
163#[serde(untagged)]
164#[allow(non_snake_case)]
165enum ErrorClassWire {
166    Named(String),
167    Backend { Backend: BackendError },
168}
169
170impl ErrorClass {
171    fn from_wire_name(name: &str) -> Self {
172        match name {
173            "InvalidArgument" => Self::InvalidArgument,
174            "CannotConnect" => Self::CannotConnect,
175            "Disconnected" => Self::Disconnected,
176            "ConnectionTimeout" => Self::ConnectionTimeout,
177            "ResponseTimeout" => Self::ResponseTimeout,
178            "Cancelled" => Self::Cancelled,
179            "ResourceExhausted" => Self::ResourceExhausted,
180            "WorkerOverloaded" => Self::WorkerOverloaded,
181            "Unavailable" => Self::Unavailable,
182            "WorkerUnavailable" => Self::WorkerUnavailable,
183            "InvalidRequest" => Self::InvalidRequest,
184            "Unauthenticated" => Self::Unauthenticated,
185            "PermissionDenied" => Self::PermissionDenied,
186            "NotFound" => Self::NotFound,
187            "Conflict" => Self::Conflict,
188            "PayloadTooLarge" => Self::PayloadTooLarge,
189            "UnsupportedMedia" => Self::UnsupportedMedia,
190            "RateLimited" => Self::RateLimited,
191            "CapacityExhausted" => Self::CapacityExhausted,
192            "BackendProtocol" => Self::BackendProtocol,
193            "DeadlineExceeded" => Self::DeadlineExceeded,
194            "NotImplemented" => Self::NotImplemented,
195            "Internal" => Self::Internal,
196            _ => Self::Unknown,
197        }
198    }
199}
200
201impl<'de> Deserialize<'de> for ErrorClass {
202    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
203    where
204        D: serde::Deserializer<'de>,
205    {
206        Ok(match ErrorClassWire::deserialize(deserializer)? {
207            ErrorClassWire::Named(name) => Self::from_wire_name(&name),
208            ErrorClassWire::Backend { Backend: error } => Self::Backend(error),
209        })
210    }
211}
212
213impl fmt::Display for ErrorClass {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        match self {
216            ErrorClass::Unknown => write!(f, "Unknown"),
217            ErrorClass::InvalidArgument => write!(f, "InvalidArgument"),
218            ErrorClass::CannotConnect => write!(f, "CannotConnect"),
219            ErrorClass::Disconnected => write!(f, "Disconnected"),
220            ErrorClass::ConnectionTimeout => write!(f, "ConnectionTimeout"),
221            ErrorClass::ResponseTimeout => write!(f, "ResponseTimeout"),
222            ErrorClass::Cancelled => write!(f, "Cancelled"),
223            ErrorClass::ResourceExhausted => write!(f, "ResourceExhausted"),
224            ErrorClass::WorkerOverloaded => write!(f, "WorkerOverloaded"),
225            ErrorClass::Unavailable => write!(f, "Unavailable"),
226            ErrorClass::WorkerUnavailable => write!(f, "WorkerUnavailable"),
227            ErrorClass::Backend(sub) => write!(f, "Backend{sub}"),
228            ErrorClass::InvalidRequest => write!(f, "InvalidRequest"),
229            ErrorClass::Unauthenticated => write!(f, "Unauthenticated"),
230            ErrorClass::PermissionDenied => write!(f, "PermissionDenied"),
231            ErrorClass::NotFound => write!(f, "NotFound"),
232            ErrorClass::Conflict => write!(f, "Conflict"),
233            ErrorClass::PayloadTooLarge => write!(f, "PayloadTooLarge"),
234            ErrorClass::UnsupportedMedia => write!(f, "UnsupportedMedia"),
235            ErrorClass::RateLimited => write!(f, "RateLimited"),
236            ErrorClass::CapacityExhausted => write!(f, "CapacityExhausted"),
237            ErrorClass::BackendProtocol => write!(f, "BackendProtocol"),
238            ErrorClass::DeadlineExceeded => write!(f, "DeadlineExceeded"),
239            ErrorClass::NotImplemented => write!(f, "NotImplemented"),
240            ErrorClass::Internal => write!(f, "Internal"),
241        }
242    }
243}
244
245impl ErrorClass {
246    /// Return the canonical semantic class for a legacy or backend-specific variant.
247    pub fn as_str(self) -> &'static str {
248        match self {
249            Self::Unknown => "Unknown",
250            Self::InvalidArgument => "InvalidArgument",
251            Self::CannotConnect => "CannotConnect",
252            Self::Disconnected => "Disconnected",
253            Self::ConnectionTimeout => "ConnectionTimeout",
254            Self::ResponseTimeout => "ResponseTimeout",
255            Self::Cancelled => "Cancelled",
256            Self::ResourceExhausted => "ResourceExhausted",
257            Self::WorkerOverloaded => "WorkerOverloaded",
258            Self::Unavailable => "Unavailable",
259            Self::WorkerUnavailable => "WorkerUnavailable",
260            Self::Backend(BackendError::Unknown) => "BackendUnknown",
261            Self::Backend(BackendError::InvalidArgument) => "BackendInvalidArgument",
262            Self::Backend(BackendError::CannotConnect) => "BackendCannotConnect",
263            Self::Backend(BackendError::Disconnected) => "BackendDisconnected",
264            Self::Backend(BackendError::ConnectionTimeout) => "BackendConnectionTimeout",
265            Self::Backend(BackendError::ResponseTimeout) => "BackendResponseTimeout",
266            Self::Backend(BackendError::Cancelled) => "BackendCancelled",
267            Self::Backend(BackendError::EngineShutdown) => "BackendEngineShutdown",
268            Self::Backend(BackendError::StreamIncomplete) => "BackendStreamIncomplete",
269            Self::InvalidRequest => "InvalidRequest",
270            Self::Unauthenticated => "Unauthenticated",
271            Self::PermissionDenied => "PermissionDenied",
272            Self::NotFound => "NotFound",
273            Self::Conflict => "Conflict",
274            Self::PayloadTooLarge => "PayloadTooLarge",
275            Self::UnsupportedMedia => "UnsupportedMedia",
276            Self::RateLimited => "RateLimited",
277            Self::CapacityExhausted => "CapacityExhausted",
278            Self::BackendProtocol => "BackendProtocol",
279            Self::DeadlineExceeded => "DeadlineExceeded",
280            Self::NotImplemented => "NotImplemented",
281            Self::Internal => "Internal",
282        }
283    }
284
285    /// Map a legacy or backend-specific variant to its canonical semantic class.
286    ///
287    /// New producers should construct canonical classes directly. [`DynamoError::class`] applies this mapping after validating the class/reason identity and is the normal consumer entry point. Serialization also emits that validated canonical class, while retaining a compatible legacy representation during the migration window.
288    pub fn normalized(self) -> Self {
289        match self {
290            Self::Unknown => Self::Internal,
291            Self::InvalidArgument => Self::InvalidRequest,
292            Self::CannotConnect | Self::Disconnected | Self::WorkerUnavailable => Self::Unavailable,
293            Self::ConnectionTimeout | Self::ResponseTimeout => Self::DeadlineExceeded,
294            Self::ResourceExhausted | Self::WorkerOverloaded => Self::CapacityExhausted,
295            Self::Backend(error) => match error {
296                BackendError::Unknown => Self::Internal,
297                BackendError::InvalidArgument => Self::InvalidRequest,
298                BackendError::CannotConnect
299                | BackendError::Disconnected
300                | BackendError::EngineShutdown
301                | BackendError::StreamIncomplete => Self::Unavailable,
302                BackendError::ConnectionTimeout | BackendError::ResponseTimeout => {
303                    Self::DeadlineExceeded
304                }
305                BackendError::Cancelled => Self::Cancelled,
306            },
307            canonical @ (Self::Cancelled
308            | Self::Unavailable
309            | Self::InvalidRequest
310            | Self::Unauthenticated
311            | Self::PermissionDenied
312            | Self::NotFound
313            | Self::Conflict
314            | Self::PayloadTooLarge
315            | Self::UnsupportedMedia
316            | Self::RateLimited
317            | Self::CapacityExhausted
318            | Self::BackendProtocol
319            | Self::DeadlineExceeded
320            | Self::NotImplemented
321            | Self::Internal) => canonical,
322        }
323    }
324}
325
326/// Backward-compatible name retained while callers migrate to ErrorClass.
327pub type ErrorType = ErrorClass;
328
329/// Categorizes errors into a fixed set of standard types.
330///
331/// Consumers (e.g., the migration module) inspect the error type to decide
332/// what action to take, rather than the error defining its own behavior.
333/// Backend engine error subcategories.
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
335pub enum BackendError {
336    /// Uncategorized or unknown backend error.
337    Unknown,
338    /// The request contains invalid input (e.g., prompt exceeds context length).
339    InvalidArgument,
340    /// Failed to establish a connection to a remote worker.
341    CannotConnect,
342    /// An established connection was lost unexpectedly.
343    Disconnected,
344    /// A connection or request timed out.
345    ConnectionTimeout,
346    /// The backend accepted the request but stopped responding (stream inactivity timeout).
347    ResponseTimeout,
348    /// The request was cancelled (e.g., client disconnected).
349    Cancelled,
350    /// The engine process has shut down or crashed.
351    EngineShutdown,
352    /// The response stream was terminated before completion (e.g., engine dropped mid-stream).
353    StreamIncomplete,
354}
355
356impl fmt::Display for BackendError {
357    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358        match self {
359            BackendError::Unknown => write!(f, "Unknown"),
360            BackendError::InvalidArgument => write!(f, "InvalidArgument"),
361            BackendError::CannotConnect => write!(f, "CannotConnect"),
362            BackendError::Disconnected => write!(f, "Disconnected"),
363            BackendError::ConnectionTimeout => write!(f, "ConnectionTimeout"),
364            BackendError::ResponseTimeout => write!(f, "ResponseTimeout"),
365            BackendError::Cancelled => write!(f, "Cancelled"),
366            BackendError::EngineShutdown => write!(f, "EngineShutdown"),
367            BackendError::StreamIncomplete => write!(f, "StreamIncomplete"),
368        }
369    }
370}
371
372/// Stable, bounded catalog key for a specific failure cause.
373#[derive(Debug, Clone, PartialEq, Eq, Hash)]
374pub struct ErrorReason(String);
375
376impl ErrorReason {
377    pub const MAX_BYTES: usize = 128;
378
379    pub fn new(value: impl Into<String>) -> Result<Self, InvalidErrorReason> {
380        let value = value.into();
381        if value.is_empty() {
382            return Err(InvalidErrorReason::Empty);
383        }
384        if value.len() > Self::MAX_BYTES {
385            return Err(InvalidErrorReason::TooLong);
386        }
387        if !value.bytes().all(|byte| {
388            byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)
389        }) {
390            return Err(InvalidErrorReason::InvalidCharacter);
391        }
392        if Self::catalog_class(&value).is_none() {
393            return Err(InvalidErrorReason::UnknownCatalogKey);
394        }
395        Ok(Self(value))
396    }
397
398    pub fn as_str(&self) -> &str {
399        &self.0
400    }
401
402    fn catalog_class(value: &str) -> Option<ErrorClass> {
403        match value {
404            "runtime.unclassified" | "runtime.invalid_error" | "runtime.internal" => {
405                Some(ErrorClass::Internal)
406            }
407            "request.invalid_argument" | "backend.invalid_argument" | "request.invalid" => {
408                Some(ErrorClass::InvalidRequest)
409            }
410            "transport.cannot_connect"
411            | "transport.disconnected"
412            | "backend.unavailable"
413            | "backend.worker_unavailable"
414            | "backend.cannot_connect"
415            | "backend.disconnected"
416            | "backend.engine_shutdown"
417            | "backend.stream_incomplete" => Some(ErrorClass::Unavailable),
418            "transport.connection_timeout"
419            | "backend.response_timeout"
420            | "backend.connection_timeout"
421            | "request.deadline_exceeded"
422            | "router.queue_deadline_exceeded" => Some(ErrorClass::DeadlineExceeded),
423            "request.cancelled" | "backend.cancelled" => Some(ErrorClass::Cancelled),
424            "capacity.pool_exhausted" | "capacity.worker_overloaded" | "capacity.exhausted" => {
425                Some(ErrorClass::CapacityExhausted)
426            }
427            "backend.unknown" => Some(ErrorClass::Internal),
428            "backend.protocol" => Some(ErrorClass::BackendProtocol),
429            "request.unauthenticated" => Some(ErrorClass::Unauthenticated),
430            "request.permission_denied" => Some(ErrorClass::PermissionDenied),
431            "request.not_found" => Some(ErrorClass::NotFound),
432            "request.conflict" => Some(ErrorClass::Conflict),
433            "request.payload_too_large" => Some(ErrorClass::PayloadTooLarge),
434            "request.unsupported_media" => Some(ErrorClass::UnsupportedMedia),
435            "request.rate_limited" => Some(ErrorClass::RateLimited),
436            "runtime.not_implemented" => Some(ErrorClass::NotImplemented),
437            _ => None,
438        }
439    }
440
441    fn from_static(value: &'static str) -> Self {
442        debug_assert!(!value.is_empty() && value.len() <= Self::MAX_BYTES);
443        debug_assert!(value.bytes().all(|byte| {
444            byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)
445        }));
446        debug_assert!(Self::catalog_class(value).is_some());
447        Self(value.to_owned())
448    }
449
450    fn for_class(class: ErrorClass) -> Self {
451        let value = match class {
452            ErrorClass::Unknown => "runtime.unclassified",
453            ErrorClass::InvalidArgument => "request.invalid_argument",
454            ErrorClass::CannotConnect => "transport.cannot_connect",
455            ErrorClass::Disconnected => "transport.disconnected",
456            ErrorClass::ConnectionTimeout => "transport.connection_timeout",
457            ErrorClass::ResponseTimeout => "backend.response_timeout",
458            ErrorClass::Cancelled => "request.cancelled",
459            ErrorClass::ResourceExhausted => "capacity.pool_exhausted",
460            ErrorClass::WorkerOverloaded => "capacity.worker_overloaded",
461            ErrorClass::Unavailable => "backend.unavailable",
462            ErrorClass::WorkerUnavailable => "backend.worker_unavailable",
463            ErrorClass::Backend(error) => match error {
464                BackendError::Unknown => "backend.unknown",
465                BackendError::InvalidArgument => "backend.invalid_argument",
466                BackendError::CannotConnect => "backend.cannot_connect",
467                BackendError::Disconnected => "backend.disconnected",
468                BackendError::ConnectionTimeout => "backend.connection_timeout",
469                BackendError::ResponseTimeout => "backend.response_timeout",
470                BackendError::Cancelled => "backend.cancelled",
471                BackendError::EngineShutdown => "backend.engine_shutdown",
472                BackendError::StreamIncomplete => "backend.stream_incomplete",
473            },
474            ErrorClass::InvalidRequest => "request.invalid",
475            ErrorClass::Unauthenticated => "request.unauthenticated",
476            ErrorClass::PermissionDenied => "request.permission_denied",
477            ErrorClass::NotFound => "request.not_found",
478            ErrorClass::Conflict => "request.conflict",
479            ErrorClass::PayloadTooLarge => "request.payload_too_large",
480            ErrorClass::UnsupportedMedia => "request.unsupported_media",
481            ErrorClass::RateLimited => "request.rate_limited",
482            ErrorClass::CapacityExhausted => "capacity.exhausted",
483            ErrorClass::BackendProtocol => "backend.protocol",
484            ErrorClass::DeadlineExceeded => "request.deadline_exceeded",
485            ErrorClass::NotImplemented => "runtime.not_implemented",
486            ErrorClass::Internal => "runtime.internal",
487        };
488        Self::from_static(value)
489    }
490}
491
492static INVALID_ERROR_REASON: LazyLock<ErrorReason> =
493    LazyLock::new(|| ErrorReason::from_static("runtime.invalid_error"));
494
495impl fmt::Display for ErrorReason {
496    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
497        f.write_str(&self.0)
498    }
499}
500
501impl Serialize for ErrorReason {
502    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
503    where
504        S: serde::Serializer,
505    {
506        serializer.serialize_str(&self.0)
507    }
508}
509
510impl<'de> Deserialize<'de> for ErrorReason {
511    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
512    where
513        D: serde::Deserializer<'de>,
514    {
515        let value = String::deserialize(deserializer)?;
516        Self::new(value).map_err(serde::de::Error::custom)
517    }
518}
519
520#[derive(Debug, Clone, Copy, PartialEq, Eq)]
521pub enum InvalidErrorReason {
522    Empty,
523    TooLong,
524    InvalidCharacter,
525    UnknownCatalogKey,
526}
527
528impl fmt::Display for InvalidErrorReason {
529    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
530        match self {
531            Self::Empty => f.write_str("error reason cannot be empty"),
532            Self::TooLong => write!(f, "error reason exceeds {} bytes", ErrorReason::MAX_BYTES),
533            Self::InvalidCharacter => f.write_str(
534                "error reason may contain only lowercase ASCII, digits, '.', '_', or '-'",
535            ),
536            Self::UnknownCatalogKey => f.write_str("error reason is not registered in the catalog"),
537        }
538    }
539}
540
541impl std::error::Error for InvalidErrorReason {}
542
543/// Closed set of structured, client-safe details.
544#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
545#[serde(tag = "type", rename_all = "snake_case")]
546pub enum PublicDetails {
547    Message {
548        message: String,
549    },
550    SizeLimit {
551        limit: u64,
552        actual: Option<u64>,
553    },
554    ContextLength {
555        limit: u64,
556        actual: Option<u64>,
557    },
558    RateLimit {
559        limit: Option<u64>,
560        remaining: Option<u64>,
561    },
562}
563
564impl PublicDetails {
565    /// Returns the client-safe rejection message when one was explicitly captured.
566    pub fn message(&self) -> Option<&str> {
567        match self {
568            Self::Message { message } => Some(message),
569            Self::SizeLimit { .. } | Self::ContextLength { .. } | Self::RateLimit { .. } => None,
570        }
571    }
572}
573
574fn bounded_message(mut message: String) -> String {
575    if message.len() > Diagnostic::MAX_BYTES {
576        let mut end = Diagnostic::MAX_BYTES - Diagnostic::TRUNCATION_SUFFIX.len();
577        while !message.is_char_boundary(end) {
578            end -= 1;
579        }
580        message.truncate(end);
581        message.push_str(Diagnostic::TRUNCATION_SUFFIX);
582    }
583    message
584}
585
586/// Bounded operator-only diagnostic text.
587#[derive(Debug, Clone, Default)]
588pub struct Diagnostic {
589    message: String,
590    source: Option<Arc<dyn std::error::Error + Send + Sync>>,
591}
592
593impl Diagnostic {
594    pub const MAX_BYTES: usize = 4096;
595    pub const TRUNCATION_SUFFIX: &'static str = "...[truncated]";
596
597    pub fn new(value: impl Into<String>) -> Self {
598        Self {
599            message: bounded_message(value.into()),
600            source: None,
601        }
602    }
603
604    fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Self {
605        self.source = Some(Arc::new(source));
606        self
607    }
608
609    pub fn as_str(&self) -> &str {
610        &self.message
611    }
612
613    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
614        self.source
615            .as_deref()
616            .map(|source| source as &(dyn std::error::Error + 'static))
617    }
618}
619
620impl PartialEq for Diagnostic {
621    fn eq(&self, other: &Self) -> bool {
622        self.message == other.message
623    }
624}
625
626impl Eq for Diagnostic {}
627
628impl Serialize for Diagnostic {
629    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
630    where
631        S: serde::Serializer,
632    {
633        serializer.serialize_str(&self.message)
634    }
635}
636
637impl<'de> Deserialize<'de> for Diagnostic {
638    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
639    where
640        D: serde::Deserializer<'de>,
641    {
642        Ok(Self::new(String::deserialize(deserializer)?))
643    }
644}
645
646// ============================================================================
647// DynamoError - The Standardized Error Type
648// ============================================================================
649
650/// The standardized error type for Dynamo.
651///
652/// `DynamoError` is a serializable semantic error that:
653/// - Carries an [`ErrorClass`] for categorization
654/// - Is serializable for network transmission via `Annotated`
655/// - Can be created from any [`std::error::Error`]
656///
657/// # Display
658///
659/// `Display` shows the private diagnostic when present and otherwise the reason.
660///
661/// ```rust,ignore
662/// let err = DynamoError::msg("outer");
663/// println!("{}", err); // "Internal: outer"
664/// ```
665#[derive(Debug, Clone, PartialEq, Eq)]
666pub struct DynamoError {
667    /// Coarse semantic category. This may retain a compatible legacy variant internally; use [`Self::class`] for validated consumer policy.
668    pub class: ErrorClass,
669    /// Registered, bounded cause key that must belong to the normalized class; use [`Self::reason`] for validated consumer policy.
670    pub reason: ErrorReason,
671    /// Optional bounded operator-only context. This is not client-safe and must not be used as a metric label.
672    pub diagnostic: Option<Diagnostic>,
673    /// Optional explicitly client-safe data. The producer remains responsible for ensuring every contained value is safe to expose.
674    pub public: Option<PublicDetails>,
675}
676
677impl Serialize for DynamoError {
678    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
679    where
680        S: serde::Serializer,
681    {
682        use serde::ser::SerializeStruct;
683
684        let public = self.public_details();
685        let caused_by = self
686            .diagnostic
687            .as_ref()
688            .and_then(Diagnostic::source)
689            .and_then(|source| source.downcast_ref::<DynamoError>());
690        let mut state = serializer.serialize_struct(
691            "DynamoError",
692            4 + usize::from(self.diagnostic.is_some())
693                + usize::from(public.is_some())
694                + usize::from(caused_by.is_some()),
695        )?;
696        state.serialize_field("error_type", &self.legacy_wire_error_type())?;
697        state.serialize_field("class", &self.class())?;
698        state.serialize_field("reason", self.reason())?;
699        state.serialize_field("message", &self.message())?;
700        if let Some(diagnostic) = &self.diagnostic {
701            state.serialize_field("diagnostic", diagnostic)?;
702        }
703        if let Some(public) = public {
704            state.serialize_field("public", public)?;
705        }
706        if let Some(caused_by) = caused_by {
707            state.serialize_field("caused_by", caused_by)?;
708        }
709        state.end()
710    }
711}
712
713impl<'de> Deserialize<'de> for DynamoError {
714    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
715    where
716        D: serde::Deserializer<'de>,
717    {
718        #[derive(Deserialize)]
719        struct Representation {
720            #[serde(default)]
721            class: Option<ErrorClass>,
722            #[serde(default)]
723            error_type: Option<ErrorClass>,
724            #[serde(default)]
725            reason: Option<String>,
726            #[serde(default)]
727            diagnostic: Option<Diagnostic>,
728            #[serde(default)]
729            message: Option<Diagnostic>,
730            #[serde(default, rename = "public", alias = "public_details")]
731            public: Option<PublicDetails>,
732            #[serde(default)]
733            caused_by: Option<Box<DynamoError>>,
734        }
735
736        let representation = Representation::deserialize(deserializer)?;
737        let declared_class = representation.class;
738        let invalid_declared_class = matches!(declared_class, Some(ErrorClass::Unknown));
739        let legacy_error_type = representation.error_type;
740        let source_class = declared_class
741            .or(legacy_error_type)
742            .unwrap_or(ErrorClass::Unknown);
743        let canonical_class = match source_class {
744            ErrorClass::Unknown => ErrorClass::Internal,
745            class => class.normalized(),
746        };
747        let reason = match representation.reason {
748            Some(reason) => ErrorReason::new(reason).ok(),
749            None => Some(ErrorReason::for_class(
750                legacy_error_type.unwrap_or(source_class),
751            )),
752        };
753        let valid_reason = !invalid_declared_class
754            && reason
755                .as_ref()
756                .and_then(|reason| ErrorReason::catalog_class(reason.as_str()))
757                .is_some_and(|class| class == canonical_class);
758        let stored_class = legacy_error_type
759            .filter(|class| *class != ErrorClass::Unknown && class.normalized() == canonical_class)
760            .unwrap_or(canonical_class);
761
762        let (class, reason, public) = match reason {
763            Some(reason) if valid_reason => (stored_class, reason, representation.public),
764            _ => (
765                ErrorClass::Internal,
766                ErrorReason::from_static("runtime.invalid_error"),
767                None,
768            ),
769        };
770
771        let diagnostic = match (
772            representation.diagnostic.or(representation.message),
773            representation.caused_by,
774        ) {
775            (Some(diagnostic), Some(caused_by)) => Some(diagnostic.with_source(*caused_by)),
776            (None, Some(caused_by)) => Some(Diagnostic::new("").with_source(*caused_by)),
777            (diagnostic, None) => diagnostic,
778        };
779
780        Ok(Self {
781            class,
782            reason,
783            diagnostic,
784            public,
785        })
786    }
787}
788
789impl DynamoError {
790    /// Create a builder for constructing a `DynamoError`.
791    pub fn builder() -> DynamoErrorBuilder {
792        DynamoErrorBuilder::default()
793    }
794
795    /// Shorthand to create an internal error with a private diagnostic.
796    pub fn msg(message: impl Into<String>) -> Self {
797        Self::builder()
798            .class(ErrorClass::Internal)
799            .reason(ErrorReason::from_static("runtime.unclassified"))
800            .diagnostic(message)
801            .build()
802    }
803
804    /// Returns the validated legacy error type without normalization.
805    ///
806    /// Invalid public-field combinations fail closed so legacy policy callers
807    /// cannot bypass the canonical identity check.
808    pub fn error_type(&self) -> ErrorType {
809        if self.has_valid_identity() {
810            self.class
811        } else {
812            ErrorClass::Internal
813        }
814    }
815
816    fn has_valid_identity(&self) -> bool {
817        ErrorReason::catalog_class(self.reason.as_str())
818            .is_some_and(|class| class == self.class.normalized())
819    }
820
821    /// Returns the canonical semantic error class.
822    pub fn class(&self) -> ErrorClass {
823        if self.has_valid_identity() {
824            self.class.normalized()
825        } else {
826            ErrorClass::Internal
827        }
828    }
829
830    fn legacy_wire_error_type(&self) -> ErrorClass {
831        match self.error_type() {
832            ErrorClass::Unknown
833            | ErrorClass::Internal
834            | ErrorClass::Unauthenticated
835            | ErrorClass::PermissionDenied
836            | ErrorClass::NotFound
837            | ErrorClass::Conflict
838            | ErrorClass::BackendProtocol
839            | ErrorClass::NotImplemented => ErrorClass::Unknown,
840            ErrorClass::InvalidArgument
841            | ErrorClass::InvalidRequest
842            | ErrorClass::PayloadTooLarge
843            | ErrorClass::UnsupportedMedia => ErrorClass::InvalidArgument,
844            ErrorClass::CannotConnect => ErrorClass::CannotConnect,
845            ErrorClass::Disconnected => ErrorClass::Disconnected,
846            ErrorClass::ConnectionTimeout => ErrorClass::ConnectionTimeout,
847            ErrorClass::ResponseTimeout | ErrorClass::DeadlineExceeded => {
848                ErrorClass::ResponseTimeout
849            }
850            ErrorClass::Cancelled => ErrorClass::Cancelled,
851            ErrorClass::ResourceExhausted
852            | ErrorClass::WorkerOverloaded
853            | ErrorClass::CapacityExhausted
854            | ErrorClass::RateLimited => ErrorClass::ResourceExhausted,
855            ErrorClass::Unavailable => ErrorClass::Unavailable,
856            ErrorClass::WorkerUnavailable => ErrorClass::WorkerUnavailable,
857            ErrorClass::Backend(error) => ErrorClass::Backend(error),
858        }
859    }
860
861    /// Returns the stable reason key.
862    pub fn reason(&self) -> &ErrorReason {
863        if self.has_valid_identity() {
864            &self.reason
865        } else {
866            &INVALID_ERROR_REASON
867        }
868    }
869
870    /// Returns the optional private diagnostic.
871    pub fn diagnostic(&self) -> Option<&Diagnostic> {
872        self.diagnostic.as_ref()
873    }
874
875    /// Returns structured client-safe details.
876    pub fn public_details(&self) -> Option<&PublicDetails> {
877        self.has_valid_identity()
878            .then_some(self.public.as_ref())
879            .flatten()
880    }
881
882    /// Returns the explicitly captured client-safe rejection message.
883    pub fn public_message(&self) -> Option<&str> {
884        self.public_details().and_then(PublicDetails::message)
885    }
886
887    /// Returns the legacy error message view.
888    pub fn message(&self) -> &str {
889        self.diagnostic
890            .as_ref()
891            .map(Diagnostic::as_str)
892            .unwrap_or_default()
893    }
894}
895
896impl fmt::Display for DynamoError {
897    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
898        match self.diagnostic() {
899            Some(diagnostic) if !diagnostic.as_str().is_empty() => {
900                write!(f, "{}: {}", self.class(), diagnostic.as_str())
901            }
902            _ => write!(f, "{}: {}", self.class(), self.reason()),
903        }
904    }
905}
906
907impl std::error::Error for DynamoError {
908    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
909        self.diagnostic.as_ref().and_then(Diagnostic::source)
910    }
911}
912
913/// Convert from a reference to any `std::error::Error`.
914impl<'a> From<&'a (dyn std::error::Error + 'static)> for DynamoError {
915    fn from(err: &'a (dyn std::error::Error + 'static)) -> Self {
916        if let Some(dynamo_err) = err.downcast_ref::<DynamoError>() {
917            return dynamo_err.clone();
918        }
919
920        let diagnostic = Diagnostic::new(err.to_string());
921        let Some(source) = err.source() else {
922            return Self {
923                class: ErrorClass::Internal,
924                reason: ErrorReason::from_static("runtime.unclassified"),
925                diagnostic: Some(diagnostic),
926                public: None,
927            };
928        };
929
930        let source = DynamoError::from(source);
931        let diagnostic = diagnostic.with_source(source.clone());
932        if source.has_valid_identity() && source.class() != ErrorClass::Internal {
933            return Self {
934                class: source.error_type(),
935                reason: source.reason().clone(),
936                diagnostic: Some(diagnostic),
937                public: source.public_details().cloned(),
938            };
939        }
940
941        Self {
942            class: ErrorClass::Internal,
943            reason: ErrorReason::from_static("runtime.unclassified"),
944            diagnostic: Some(diagnostic),
945            public: None,
946        }
947    }
948}
949
950/// Convert from an owned boxed `std::error::Error`.
951impl From<Box<dyn std::error::Error + 'static>> for DynamoError {
952    fn from(err: Box<dyn std::error::Error + 'static>) -> Self {
953        match err.downcast::<DynamoError>() {
954            Ok(dynamo_err) => *dynamo_err,
955            Err(err) => DynamoError::from(&*err as &(dyn std::error::Error + 'static)),
956        }
957    }
958}
959
960// ============================================================================
961// DynamoErrorBuilder
962// ============================================================================
963
964/// Builder for constructing a [`DynamoError`].
965///
966/// # Example
967/// ```rust,ignore
968/// let err = DynamoError::builder()
969///     .error_type(ErrorClass::Disconnected)
970///     .message("worker lost")
971///     .cause(some_io_error)
972///     .build();
973/// ```
974#[derive(Default)]
975pub struct DynamoErrorBuilder {
976    class: Option<ErrorClass>,
977    reason: Option<ErrorReason>,
978    diagnostic: Option<Diagnostic>,
979    public: Option<PublicDetails>,
980}
981
982impl DynamoErrorBuilder {
983    /// Set the legacy or canonical error class.
984    pub fn error_type(mut self, error_type: ErrorType) -> Self {
985        self.class = Some(error_type);
986        self
987    }
988
989    /// Set the canonical error class.
990    pub fn class(self, class: ErrorClass) -> Self {
991        self.error_type(class)
992    }
993
994    /// Set the stable reason key.
995    pub fn reason(mut self, reason: ErrorReason) -> Self {
996        self.reason = Some(reason);
997        self
998    }
999
1000    /// Set the private bounded diagnostic.
1001    pub fn diagnostic(mut self, diagnostic: impl Into<String>) -> Self {
1002        self.diagnostic = Some(Diagnostic::new(diagnostic));
1003        self
1004    }
1005
1006    /// Set the legacy error message view.
1007    pub fn message(self, message: impl Into<String>) -> Self {
1008        self.diagnostic(message)
1009    }
1010
1011    /// Set a bounded client-safe rejection message.
1012    pub fn public_message(mut self, message: impl Into<String>) -> Self {
1013        let message = bounded_message(message.into());
1014        self.public = Some(PublicDetails::Message { message });
1015        self
1016    }
1017
1018    /// Set structured client-safe details.
1019    pub fn public_details(mut self, public: PublicDetails) -> Self {
1020        self.public = Some(public);
1021        self
1022    }
1023
1024    /// Preserve compatibility with existing builders while keeping native causes out of the semantic payload.
1025    pub fn cause(mut self, cause: impl std::error::Error + 'static) -> Self {
1026        let message = cause.to_string();
1027        let source = DynamoError::from(&cause as &(dyn std::error::Error + 'static));
1028        let diagnostic = self
1029            .diagnostic
1030            .take()
1031            .unwrap_or_else(|| Diagnostic::new(message));
1032        self.diagnostic = Some(diagnostic.with_source(source));
1033        self
1034    }
1035
1036    /// Build the `DynamoError` and fail closed on a class/reason mismatch.
1037    pub fn build(self) -> DynamoError {
1038        let raw_class = self.class.unwrap_or(ErrorClass::Internal);
1039        let reason = self
1040            .reason
1041            .unwrap_or_else(|| ErrorReason::for_class(raw_class));
1042        let valid_reason = ErrorReason::catalog_class(reason.as_str())
1043            .is_some_and(|class| class == raw_class.normalized());
1044
1045        if valid_reason {
1046            DynamoError {
1047                class: raw_class,
1048                reason,
1049                diagnostic: self.diagnostic,
1050                public: self.public,
1051            }
1052        } else {
1053            DynamoError {
1054                class: ErrorClass::Internal,
1055                reason: ErrorReason::from_static("runtime.invalid_error"),
1056                diagnostic: self.diagnostic,
1057                public: None,
1058            }
1059        }
1060    }
1061}
1062
1063// ============================================================================
1064// Utility Functions
1065// ============================================================================
1066
1067/// Check whether an error chain contains a specific set of error types
1068/// while not containing any of the excluded error types.
1069///
1070/// Walks the chain via `source()`, inspecting each error that can be downcast
1071/// to `DynamoError`. Returns `false` immediately if any error's type is in
1072/// `exclude_set`. Otherwise, returns `true` if at least one error's type is
1073/// in `match_set`. Errors that are not `DynamoError` are skipped.
1074pub fn match_error_chain(
1075    err: &(dyn std::error::Error + 'static),
1076    match_set: &[ErrorClass],
1077    exclude_set: &[ErrorClass],
1078) -> bool {
1079    let mut found = false;
1080    let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
1081
1082    while let Some(e) = current {
1083        if let Some(dynamo_err) = e.downcast_ref::<DynamoError>() {
1084            if exclude_set.contains(&dynamo_err.error_type()) {
1085                return false;
1086            }
1087            if match_set.contains(&dynamo_err.error_type()) {
1088                found = true;
1089            }
1090        }
1091        current = e.source();
1092    }
1093
1094    found
1095}
1096
1097// ============================================================================
1098// Tests
1099// ============================================================================
1100
1101#[cfg(test)]
1102mod tests {
1103    use super::*;
1104    use std::error::Error;
1105
1106    // Compile-time assertions that DynamoError is std::error::Error + Send + Sync + 'static.
1107    // These fail at compile time if a future change breaks these guarantees.
1108    const _: () = {
1109        fn assert_stderror<T: std::error::Error>() {}
1110        fn assert_send<T: Send>() {}
1111        fn assert_sync<T: Sync>() {}
1112        fn assert_static<T: 'static>() {}
1113        fn assert_all() {
1114            assert_stderror::<DynamoError>();
1115            assert_send::<DynamoError>();
1116            assert_sync::<DynamoError>();
1117            assert_static::<DynamoError>();
1118        }
1119    };
1120
1121    #[test]
1122    fn test_msg_constructor() {
1123        let err = DynamoError::msg("something failed");
1124        assert_eq!(err.error_type(), ErrorClass::Internal);
1125        assert_eq!(err.reason().as_str(), "runtime.unclassified");
1126        assert_eq!(err.message(), "something failed");
1127        assert!(err.source().is_none());
1128    }
1129
1130    #[test]
1131    fn cause_is_preserved_for_legacy_wire_compatibility() {
1132        let err = DynamoError::builder()
1133            .class(ErrorClass::Internal)
1134            .reason(ErrorReason::new("runtime.internal").unwrap())
1135            .diagnostic("operation failed")
1136            .cause(std::io::Error::other("io error"))
1137            .build();
1138
1139        assert_eq!(err.message(), "operation failed");
1140        assert_eq!(err.source().unwrap().to_string(), "Internal: io error");
1141        let value = serde_json::to_value(&err).unwrap();
1142        assert_eq!(value.as_object().unwrap().len(), 6);
1143        assert!(value.get("caused_by").is_some());
1144
1145        let roundtrip: DynamoError = serde_json::from_value(value).unwrap();
1146        assert_eq!(
1147            roundtrip.source().unwrap().to_string(),
1148            "Internal: io error"
1149        );
1150    }
1151
1152    #[test]
1153    fn display_uses_diagnostic_or_reason() {
1154        let with_diagnostic = DynamoError::builder()
1155            .class(ErrorClass::Internal)
1156            .reason(ErrorReason::new("runtime.internal").unwrap())
1157            .diagnostic("operation failed")
1158            .build();
1159        let without_diagnostic = DynamoError::builder()
1160            .class(ErrorClass::Internal)
1161            .reason(ErrorReason::new("runtime.internal").unwrap())
1162            .build();
1163
1164        assert_eq!(with_diagnostic.to_string(), "Internal: operation failed");
1165        assert_eq!(without_diagnostic.to_string(), "Internal: runtime.internal");
1166    }
1167
1168    #[test]
1169    fn conversion_preserves_nested_semantic_source_locally() {
1170        #[derive(Debug)]
1171        struct OuterError {
1172            source: DynamoError,
1173        }
1174
1175        impl fmt::Display for OuterError {
1176            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1177                f.write_str("outer failure")
1178            }
1179        }
1180
1181        impl std::error::Error for OuterError {
1182            fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1183                Some(&self.source)
1184            }
1185        }
1186
1187        let outer = OuterError {
1188            source: DynamoError::builder()
1189                .error_type(ErrorType::InvalidArgument)
1190                .diagnostic("invalid input")
1191                .build(),
1192        };
1193        let converted = DynamoError::from(&outer as &(dyn std::error::Error + 'static));
1194
1195        assert!(match_error_chain(
1196            &converted,
1197            &[ErrorType::InvalidArgument],
1198            &[]
1199        ));
1200        let value = serde_json::to_value(converted).unwrap();
1201        assert!(value.get("caused_by").is_some());
1202    }
1203
1204    #[test]
1205    fn test_from_boxed_std_error() {
1206        let std_err = std::io::Error::other("io error");
1207        let boxed: Box<dyn std::error::Error> = Box::new(std_err);
1208        let dynamo_err = DynamoError::from(boxed);
1209
1210        assert_eq!(dynamo_err.class(), ErrorClass::Internal);
1211        assert_eq!(dynamo_err.reason().as_str(), "runtime.unclassified");
1212        assert_eq!(dynamo_err.message(), "io error");
1213    }
1214
1215    #[test]
1216    fn test_from_boxed_takes_ownership_of_dynamo_error() {
1217        let inner = DynamoError::msg("original");
1218        let boxed: Box<dyn std::error::Error> = Box::new(inner);
1219        let dynamo_err = DynamoError::from(boxed);
1220
1221        assert_eq!(dynamo_err.class(), ErrorClass::Internal);
1222        assert_eq!(dynamo_err.message(), "original");
1223    }
1224
1225    #[test]
1226    fn semantic_metadata_roundtrips() {
1227        let err = DynamoError::builder()
1228            .class(ErrorClass::RateLimited)
1229            .reason(ErrorReason::new("request.rate_limited").unwrap())
1230            .diagnostic("request rate exceeded")
1231            .public_details(PublicDetails::RateLimit {
1232                limit: Some(100),
1233                remaining: Some(0),
1234            })
1235            .build();
1236
1237        let json = serde_json::to_string(&err).unwrap();
1238        let deserialized: DynamoError = serde_json::from_str(&json).unwrap();
1239
1240        assert_eq!(deserialized.class(), ErrorClass::RateLimited);
1241        assert_eq!(deserialized.reason().as_str(), "request.rate_limited");
1242        assert_eq!(
1243            deserialized.diagnostic().map(Diagnostic::as_str),
1244            Some("request rate exceeded")
1245        );
1246        assert_eq!(
1247            deserialized.public_details(),
1248            Some(&PublicDetails::RateLimit {
1249                limit: Some(100),
1250                remaining: Some(0),
1251            })
1252        );
1253    }
1254
1255    #[test]
1256    fn semantic_schema_includes_legacy_compatibility_fields() {
1257        let err = DynamoError::builder()
1258            .class(ErrorClass::RateLimited)
1259            .reason(ErrorReason::new("request.rate_limited").unwrap())
1260            .diagnostic("request rate exceeded")
1261            .public_details(PublicDetails::RateLimit {
1262                limit: Some(100),
1263                remaining: Some(0),
1264            })
1265            .build();
1266
1267        let value = serde_json::to_value(err).unwrap();
1268        let object = value.as_object().unwrap();
1269
1270        assert_eq!(object.len(), 6);
1271        assert_eq!(value["error_type"], "ResourceExhausted");
1272        assert_eq!(value["message"], "request rate exceeded");
1273        assert_eq!(value["class"], "RateLimited");
1274        assert_eq!(value["reason"], "request.rate_limited");
1275        assert_eq!(value["diagnostic"], "request rate exceeded");
1276        assert!(value.get("public").is_some());
1277    }
1278
1279    #[test]
1280    fn optional_semantic_fields_are_omitted() {
1281        let err = DynamoError::builder()
1282            .class(ErrorClass::InvalidRequest)
1283            .reason(ErrorReason::new("request.invalid").unwrap())
1284            .build();
1285
1286        let value = serde_json::to_value(err).unwrap();
1287        let object = value.as_object().unwrap();
1288
1289        assert_eq!(object.len(), 4);
1290        assert_eq!(value["error_type"], "InvalidArgument");
1291        assert_eq!(value["message"], "");
1292        assert_eq!(value["class"], "InvalidRequest");
1293        assert_eq!(value["reason"], "request.invalid");
1294        assert!(value.get("diagnostic").is_none());
1295        assert!(value.get("public").is_none());
1296    }
1297
1298    #[test]
1299    fn unknown_catalog_reason_fails_closed() {
1300        let json = r#"{
1301            "class": "InvalidRequest",
1302            "reason": "request.user_supplied_metric_label",
1303            "diagnostic": "private details"
1304        }"#;
1305        let err: DynamoError = serde_json::from_str(json).unwrap();
1306
1307        assert_eq!(err.class(), ErrorClass::Internal);
1308        assert_eq!(err.reason().as_str(), "runtime.invalid_error");
1309        assert_eq!(
1310            err.diagnostic().map(Diagnostic::as_str),
1311            Some("private details")
1312        );
1313    }
1314
1315    #[test]
1316    fn public_fields_fail_closed_at_consumer_boundaries() {
1317        let err = DynamoError {
1318            class: ErrorClass::InvalidRequest,
1319            reason: ErrorReason::new("request.rate_limited").unwrap(),
1320            diagnostic: Some(Diagnostic::new("private details")),
1321            public: Some(PublicDetails::RateLimit {
1322                limit: Some(10),
1323                remaining: Some(0),
1324            }),
1325        };
1326
1327        assert_eq!(err.class(), ErrorClass::Internal);
1328        assert_eq!(err.error_type(), ErrorClass::Internal);
1329        assert_eq!(err.reason().as_str(), "runtime.invalid_error");
1330        assert!(err.public_details().is_none());
1331        assert_eq!(err.to_string(), "Internal: private details");
1332        assert_eq!(
1333            serde_json::to_value(err).unwrap(),
1334            serde_json::json!({
1335                "error_type": "Unknown",
1336                "message": "private details",
1337                "class": "Internal",
1338                "reason": "runtime.invalid_error",
1339                "diagnostic": "private details"
1340            })
1341        );
1342    }
1343
1344    #[test]
1345    fn diagnostic_is_bounded_at_utf8_boundary() {
1346        let truncation_index = Diagnostic::MAX_BYTES - Diagnostic::TRUNCATION_SUFFIX.len();
1347        let diagnostic = Diagnostic::new(
1348            "x".repeat(truncation_index - 1) + "é" + &"x".repeat(Diagnostic::MAX_BYTES),
1349        );
1350
1351        assert!(diagnostic.as_str().len() <= Diagnostic::MAX_BYTES);
1352        assert!(
1353            diagnostic
1354                .as_str()
1355                .is_char_boundary(diagnostic.as_str().len())
1356        );
1357        assert!(diagnostic.as_str().ends_with(Diagnostic::TRUNCATION_SUFFIX));
1358    }
1359
1360    #[test]
1361    fn public_message_is_bounded_at_utf8_boundary() {
1362        let truncation_index = Diagnostic::MAX_BYTES - Diagnostic::TRUNCATION_SUFFIX.len();
1363        let message = "x".repeat(truncation_index - 1) + "é" + &"x".repeat(Diagnostic::MAX_BYTES);
1364        let error = DynamoError::builder()
1365            .class(ErrorClass::InvalidRequest)
1366            .public_message(message)
1367            .build();
1368        let public_message = error.public_message().unwrap();
1369
1370        assert!(public_message.len() <= Diagnostic::MAX_BYTES);
1371        assert!(public_message.is_char_boundary(public_message.len()));
1372        assert!(public_message.ends_with(Diagnostic::TRUNCATION_SUFFIX));
1373    }
1374
1375    #[test]
1376    fn legacy_builder_derives_semantic_defaults() {
1377        let legacy_type: ErrorType = ErrorType::InvalidArgument;
1378        let err = DynamoError::builder()
1379            .error_type(legacy_type)
1380            .message("bad request")
1381            .build();
1382
1383        assert_eq!(err.error_type(), ErrorType::InvalidArgument);
1384        assert_eq!(err.class(), ErrorClass::InvalidRequest);
1385        assert_eq!(err.reason().as_str(), "request.invalid_argument");
1386        assert_eq!(err.message(), "bad request");
1387    }
1388
1389    #[test]
1390    fn legacy_json_derives_semantic_defaults() {
1391        let json = r#"{"error_type":"InvalidArgument","message":"bad request"}"#;
1392        let err: DynamoError = serde_json::from_str(json).unwrap();
1393
1394        assert_eq!(err.class(), ErrorClass::InvalidRequest);
1395        assert_eq!(err.reason().as_str(), "request.invalid_argument");
1396        assert_eq!(
1397            err.diagnostic().map(Diagnostic::as_str),
1398            Some("bad request")
1399        );
1400        assert!(err.public_details().is_none());
1401    }
1402
1403    #[test]
1404    fn legacy_unknown_preserves_nested_classification_across_the_wire() {
1405        let json = r#"{
1406            "error_type":"Unknown",
1407            "message":"generate failed",
1408            "caused_by":{"error_type":"InvalidArgument","message":"bad request"}
1409        }"#;
1410        let err: DynamoError = serde_json::from_str(json).unwrap();
1411
1412        assert_eq!(err.reason().as_str(), "runtime.unclassified");
1413        assert_eq!(err.class(), ErrorClass::Internal);
1414        assert!(match_error_chain(&err, &[ErrorClass::InvalidArgument], &[]));
1415
1416        let reserialized = serde_json::to_value(&err).unwrap();
1417        assert_eq!(reserialized["caused_by"]["error_type"], "InvalidArgument");
1418    }
1419
1420    #[test]
1421    fn backend_class_roundtrips_as_its_canonical_class() {
1422        let error = DynamoError::builder()
1423            .error_type(ErrorClass::Backend(BackendError::InvalidArgument))
1424            .build();
1425
1426        let serialized = serde_json::to_string(&error).unwrap();
1427        assert!(serialized.contains("\"class\":\"InvalidRequest\""));
1428
1429        let decoded: DynamoError = serde_json::from_str(&serialized).unwrap();
1430        assert_eq!(
1431            decoded.error_type(),
1432            ErrorClass::Backend(BackendError::InvalidArgument)
1433        );
1434        assert_eq!(decoded.class(), ErrorClass::InvalidRequest);
1435        assert_eq!(decoded.reason().as_str(), "backend.invalid_argument");
1436    }
1437
1438    #[test]
1439    fn transport_subtype_roundtrips() {
1440        let error = DynamoError::builder()
1441            .error_type(ErrorClass::CannotConnect)
1442            .build();
1443
1444        let decoded: DynamoError =
1445            serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap();
1446
1447        assert_eq!(decoded.error_type(), ErrorClass::CannotConnect);
1448        assert_eq!(decoded.class(), ErrorClass::Unavailable);
1449        assert_eq!(decoded.reason().as_str(), "transport.cannot_connect");
1450    }
1451
1452    #[test]
1453    fn worker_unavailable_roundtrips_with_semantic_identity() {
1454        let error = DynamoError::builder()
1455            .error_type(ErrorClass::WorkerUnavailable)
1456            .build();
1457
1458        let decoded: DynamoError =
1459            serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap();
1460
1461        assert_eq!(decoded.error_type(), ErrorClass::WorkerUnavailable);
1462        assert_eq!(decoded.class(), ErrorClass::Unavailable);
1463        assert_eq!(decoded.reason().as_str(), "backend.worker_unavailable");
1464    }
1465
1466    #[test]
1467    fn response_timeout_subtypes_roundtrip() {
1468        for error_type in [
1469            ErrorClass::ResponseTimeout,
1470            ErrorClass::Backend(BackendError::ResponseTimeout),
1471        ] {
1472            let error = DynamoError::builder().error_type(error_type).build();
1473            let decoded: DynamoError =
1474                serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap();
1475
1476            assert_eq!(decoded.error_type(), error_type);
1477            assert_eq!(decoded.class(), ErrorClass::DeadlineExceeded);
1478        }
1479    }
1480
1481    #[test]
1482    fn unknown_class_fails_closed_during_deserialization() {
1483        let json = r#"{
1484            "class":"FutureErrorClass",
1485            "reason":"runtime.internal",
1486            "public":{"type":"message","message":"must not escape"}
1487        }"#;
1488        let error: DynamoError = serde_json::from_str(json).unwrap();
1489
1490        assert_eq!(error.error_type(), ErrorClass::Internal);
1491        assert_eq!(error.class(), ErrorClass::Internal);
1492        assert_eq!(error.reason().as_str(), "runtime.invalid_error");
1493        assert!(error.public_details().is_none());
1494    }
1495
1496    #[test]
1497    fn error_class_deserialization_preserves_backend_and_fails_closed() {
1498        let backend: ErrorClass = serde_json::from_str(r#"{"Backend":"InvalidArgument"}"#).unwrap();
1499        assert_eq!(backend, ErrorClass::Backend(BackendError::InvalidArgument));
1500
1501        let unknown: ErrorClass = serde_json::from_str(r#""FutureErrorClass""#).unwrap();
1502        assert_eq!(unknown, ErrorClass::Unknown);
1503    }
1504
1505    #[test]
1506    fn wrapping_preserves_the_semantic_head_across_the_wire() {
1507        #[derive(Debug)]
1508        struct Wrapper(DynamoError);
1509
1510        impl fmt::Display for Wrapper {
1511            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1512                f.write_str("transport wrapper")
1513            }
1514        }
1515
1516        impl std::error::Error for Wrapper {
1517            fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1518                Some(&self.0)
1519            }
1520        }
1521
1522        let wrapped = Wrapper(
1523            DynamoError::builder()
1524                .class(ErrorClass::Unavailable)
1525                .reason(ErrorReason::new("transport.disconnected").unwrap())
1526                .build(),
1527        );
1528        let error = DynamoError::from(&wrapped as &(dyn std::error::Error + 'static));
1529
1530        assert_eq!(error.class(), ErrorClass::Unavailable);
1531        assert_eq!(error.reason().as_str(), "transport.disconnected");
1532        let value = serde_json::to_value(&error).unwrap();
1533        assert!(value.get("caused_by").is_some());
1534        let decoded: DynamoError = serde_json::from_value(value).unwrap();
1535        assert_eq!(decoded.class(), ErrorClass::Unavailable);
1536        assert_eq!(decoded.reason().as_str(), "transport.disconnected");
1537    }
1538
1539    #[test]
1540    fn malformed_semantic_reason_fails_closed() {
1541        let json = r#"{
1542            "class": "InvalidRequest",
1543            "reason": "INVALID REASON",
1544            "diagnostic": "private details",
1545            "public": {"type": "rate_limit", "limit": 10, "remaining": 0}
1546        }"#;
1547        let err: DynamoError = serde_json::from_str(json).unwrap();
1548
1549        assert_eq!(err.error_type(), ErrorClass::Internal);
1550        assert_eq!(err.class(), ErrorClass::Internal);
1551        assert_eq!(err.reason().as_str(), "runtime.invalid_error");
1552        assert!(err.public_details().is_none());
1553    }
1554
1555    #[test]
1556    fn test_error_type_display() {
1557        assert_eq!(ErrorClass::Unknown.to_string(), "Unknown");
1558        assert_eq!(ErrorClass::InvalidArgument.to_string(), "InvalidArgument");
1559        assert_eq!(ErrorClass::CannotConnect.to_string(), "CannotConnect");
1560        assert_eq!(ErrorClass::Disconnected.to_string(), "Disconnected");
1561        assert_eq!(
1562            ErrorClass::ConnectionTimeout.to_string(),
1563            "ConnectionTimeout"
1564        );
1565        assert_eq!(ErrorClass::ResponseTimeout.to_string(), "ResponseTimeout");
1566        assert_eq!(ErrorClass::Cancelled.to_string(), "Cancelled");
1567        assert_eq!(ErrorClass::DeadlineExceeded.to_string(), "DeadlineExceeded");
1568        assert_eq!(
1569            ErrorClass::ResourceExhausted.to_string(),
1570            "ResourceExhausted"
1571        );
1572        assert_eq!(ErrorClass::WorkerOverloaded.to_string(), "WorkerOverloaded");
1573        assert_eq!(ErrorClass::Unavailable.to_string(), "Unavailable");
1574        assert_eq!(
1575            ErrorClass::WorkerUnavailable.to_string(),
1576            "WorkerUnavailable"
1577        );
1578        assert_eq!(
1579            ErrorClass::Backend(BackendError::Unknown).to_string(),
1580            "BackendUnknown"
1581        );
1582        assert_eq!(
1583            ErrorClass::Backend(BackendError::InvalidArgument).to_string(),
1584            "BackendInvalidArgument"
1585        );
1586        assert_eq!(
1587            ErrorClass::Backend(BackendError::CannotConnect).to_string(),
1588            "BackendCannotConnect"
1589        );
1590        assert_eq!(
1591            ErrorClass::Backend(BackendError::Disconnected).to_string(),
1592            "BackendDisconnected"
1593        );
1594        assert_eq!(
1595            ErrorClass::Backend(BackendError::ConnectionTimeout).to_string(),
1596            "BackendConnectionTimeout"
1597        );
1598        assert_eq!(
1599            ErrorClass::Backend(BackendError::Cancelled).to_string(),
1600            "BackendCancelled"
1601        );
1602        assert_eq!(
1603            ErrorClass::Backend(BackendError::EngineShutdown).to_string(),
1604            "BackendEngineShutdown"
1605        );
1606        assert_eq!(
1607            ErrorClass::Backend(BackendError::StreamIncomplete).to_string(),
1608            "BackendStreamIncomplete"
1609        );
1610        assert_eq!(
1611            ErrorClass::Backend(BackendError::ResponseTimeout).to_string(),
1612            "BackendResponseTimeout"
1613        );
1614    }
1615}