Skip to main content

spiral_contract/
call.rs

1//! Transport-independent failures returned by service implementations.
2
3use std::error::Error;
4use std::fmt::{self, Debug, Display, Formatter};
5
6use crate::error::{ErrorEnvelope, ServiceError, canonical_http_status, new_error_instance_id};
7
8/// An owned failure that can cross asynchronous service boundaries.
9pub type BoxError = Box<dyn Error + Send + Sync + 'static>;
10
11/// A well-formed service rejection and its optional typed interpretation.
12pub struct ServiceFailure<E> {
13    envelope: ErrorEnvelope,
14    declared: Option<E>,
15    observed_status: Option<u16>,
16}
17
18impl<E> ServiceFailure<E> {
19    /// Construct a locally-created error that is not declared by the service.
20    pub fn undeclared(envelope: ErrorEnvelope) -> Self {
21        Self {
22            envelope,
23            declared: None,
24            observed_status: None,
25        }
26    }
27
28    /// Return the authoritative wire envelope.
29    pub fn envelope(&self) -> &ErrorEnvelope {
30        &self.envelope
31    }
32
33    /// Return the typed error when the envelope exactly matches this client contract.
34    pub fn declared(&self) -> Option<&E> {
35        self.declared.as_ref()
36    }
37
38    /// Consume this failure if it has a typed declared interpretation.
39    pub fn into_declared(mut self) -> Result<E, Self> {
40        match self.declared.take() {
41            Some(error) => Ok(error),
42            None => Err(self),
43        }
44    }
45
46    /// Return the canonical HTTP status implied by the envelope's error code.
47    pub fn canonical_status(&self) -> u16 {
48        canonical_http_status(&self.envelope.error_code)
49    }
50
51    /// Return the status observed on an HTTP response, if this failure crossed HTTP.
52    pub fn observed_status(&self) -> Option<u16> {
53        self.observed_status
54    }
55
56    /// Return the observed HTTP status, or the envelope's canonical status for a local failure.
57    pub fn status(&self) -> u16 {
58        self.observed_status
59            .unwrap_or_else(|| self.canonical_status())
60    }
61
62    fn into_local_undeclared<T>(self) -> ServiceFailure<T> {
63        ServiceFailure {
64            envelope: self.envelope,
65            declared: None,
66            observed_status: None,
67        }
68    }
69}
70
71impl<E: ServiceError> ServiceFailure<E> {
72    fn locally_declared(error: E) -> Self {
73        let envelope = error.to_envelope(new_error_instance_id());
74        Self {
75            envelope,
76            declared: Some(error),
77            observed_status: None,
78        }
79    }
80
81    /// Decode a remote service failure without discarding its actual HTTP status.
82    pub fn remote(status: u16, envelope: ErrorEnvelope) -> Self {
83        let declared = (canonical_http_status(&envelope.error_code) == status)
84            .then(|| E::from_envelope(&envelope))
85            .flatten();
86        Self {
87            envelope,
88            declared,
89            observed_status: Some(status),
90        }
91    }
92}
93
94/// A bounded response body retained for an error that did not contain a valid envelope.
95pub struct UnknownRemote {
96    status: u16,
97    content_type: Option<String>,
98    body: Vec<u8>,
99    truncated: bool,
100    source: Option<BoxError>,
101}
102
103impl UnknownRemote {
104    /// Return the HTTP status observed on the response.
105    pub fn status(&self) -> u16 {
106        self.status
107    }
108
109    /// Return the response content type when it was a valid text header.
110    pub fn content_type(&self) -> Option<&str> {
111        self.content_type.as_deref()
112    }
113
114    /// Return the bounded response body prefix.
115    pub fn body(&self) -> &[u8] {
116        &self.body
117    }
118
119    /// Report whether the response exceeded the retained body limit.
120    pub fn truncated(&self) -> bool {
121        self.truncated
122    }
123}
124
125/// A request transport failure, optionally after response headers were received.
126pub struct TransportFailure {
127    source: BoxError,
128    observed_status: Option<u16>,
129}
130
131impl TransportFailure {
132    /// Return the response status when headers were received before the transport failed.
133    pub fn observed_status(&self) -> Option<u16> {
134        self.observed_status
135    }
136}
137
138/// A complete response that did not satisfy the service wire contract.
139pub struct ProtocolFailure {
140    message: String,
141    observed_status: Option<u16>,
142}
143
144impl ProtocolFailure {
145    /// Return a redaction-safe description of the violated contract.
146    pub fn message(&self) -> &str {
147        &self.message
148    }
149
150    /// Return the response status when the violation followed response headers.
151    pub fn observed_status(&self) -> Option<u16> {
152        self.observed_status
153    }
154}
155
156/// A service call failure, independent of local or HTTP dispatch.
157pub enum CallError<E> {
158    /// A well-formed service error returned locally or by an HTTP peer.
159    Service(ServiceFailure<E>),
160    /// A non-successful HTTP response without a valid service envelope.
161    UnknownRemote(UnknownRemote),
162    /// The transport failed before completing a service response.
163    Transport(TransportFailure),
164    /// A complete response did not satisfy the service wire contract.
165    Protocol(ProtocolFailure),
166    /// The local implementation encountered an undeclared failure.
167    Internal {
168        /// Underlying failure retained for server diagnostics.
169        source: BoxError,
170        /// Correlation identity returned to callers and emitted in server logs.
171        error_instance_id: String,
172    },
173}
174
175impl<E> CallError<E> {
176    /// Preserve a transport failure that occurred before response headers arrived.
177    pub fn transport(error: impl Into<BoxError>) -> Self {
178        Self::Transport(TransportFailure {
179            source: error.into(),
180            observed_status: None,
181        })
182    }
183
184    /// Preserve a transport failure that occurred after response headers arrived.
185    pub fn transport_response(status: u16, error: impl Into<BoxError>) -> Self {
186        Self::Transport(TransportFailure {
187            source: error.into(),
188            observed_status: Some(status),
189        })
190    }
191
192    /// Record a malformed request configuration or response without observed headers.
193    pub fn protocol(message: impl Into<String>) -> Self {
194        Self::Protocol(ProtocolFailure {
195            message: message.into(),
196            observed_status: None,
197        })
198    }
199
200    /// Record a response that violated the wire contract.
201    pub fn protocol_response(status: u16, message: impl Into<String>) -> Self {
202        Self::Protocol(ProtocolFailure {
203            message: message.into(),
204            observed_status: Some(status),
205        })
206    }
207
208    /// Preserve a non-successful HTTP response without a valid error envelope.
209    pub fn unknown_remote(
210        status: u16,
211        content_type: Option<String>,
212        body: Vec<u8>,
213        truncated: bool,
214    ) -> Self {
215        Self::UnknownRemote(UnknownRemote {
216            status,
217            content_type,
218            body,
219            truncated,
220            source: None,
221        })
222    }
223
224    /// Preserve a failed error-body read together with response information received first.
225    pub fn unreadable_remote(
226        status: u16,
227        content_type: Option<String>,
228        body: Vec<u8>,
229        error: impl Into<BoxError>,
230    ) -> Self {
231        Self::UnknownRemote(UnknownRemote {
232            status,
233            content_type,
234            body,
235            truncated: false,
236            source: Some(error.into()),
237        })
238    }
239
240    /// Create a well-formed locally-raised error outside the declared error set.
241    pub fn service(envelope: ErrorEnvelope) -> Self {
242        Self::Service(ServiceFailure::undeclared(envelope))
243    }
244
245    /// Preserve an underlying local failure with a new correlation identity.
246    pub fn internal(error: impl Into<BoxError>) -> Self {
247        Self::Internal {
248            source: error.into(),
249            error_instance_id: new_error_instance_id(),
250        }
251    }
252
253    /// Construct the shared missing-or-invalid-credentials error.
254    pub fn unauthenticated() -> Self {
255        Self::service(ErrorEnvelope::new(
256            "UNAUTHENTICATED",
257            "Default:Unauthenticated",
258            new_error_instance_id(),
259        ))
260    }
261
262    /// Construct the shared authorization-denial error.
263    pub fn permission_denied(operation: impl Into<String>, resource: impl Into<String>) -> Self {
264        let mut envelope = ErrorEnvelope::new(
265            "PERMISSION_DENIED",
266            "Default:PermissionDenied",
267            new_error_instance_id(),
268        );
269        envelope
270            .parameters
271            .insert("operation".into(), operation.into());
272        envelope
273            .parameters
274            .insert("resource".into(), resource.into());
275        Self::service(envelope)
276    }
277
278    /// Return the correlation identity carried by a service or internal error.
279    pub fn error_instance_id(&self) -> Option<&str> {
280        match self {
281            Self::Service(failure) => Some(&failure.envelope.error_instance_id),
282            Self::Internal {
283                error_instance_id, ..
284            } => Some(error_instance_id),
285            Self::UnknownRemote(_) | Self::Transport(_) | Self::Protocol(_) => None,
286        }
287    }
288
289    /// Propagate caller authentication failures and hide every other dependency failure.
290    pub fn from_dependency<F>(error: CallError<F>) -> Self
291    where
292        F: Send + Sync + 'static,
293    {
294        match error {
295            CallError::Service(failure) if matches!(failure.canonical_status(), 401 | 403) => {
296                Self::Service(failure.into_local_undeclared())
297            }
298            error => {
299                let error_instance_id = error
300                    .error_instance_id()
301                    .map(ToOwned::to_owned)
302                    .unwrap_or_else(new_error_instance_id);
303                Self::Internal {
304                    source: Box::new(error),
305                    error_instance_id,
306                }
307            }
308        }
309    }
310}
311
312impl<E: ServiceError> From<E> for CallError<E> {
313    fn from(error: E) -> Self {
314        Self::Service(ServiceFailure::locally_declared(error))
315    }
316}
317
318impl<E> Display for CallError<E> {
319    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
320        formatter.write_str(match self {
321            Self::Service(_) => "service call rejected",
322            Self::UnknownRemote(_) => "remote service returned an unknown error",
323            Self::Transport(_) => "service transport failed",
324            Self::Protocol(_) => "invalid service response",
325            Self::Internal { .. } => "service implementation failed",
326        })
327    }
328}
329
330impl<E> Debug for CallError<E> {
331    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
332        formatter.write_str(match self {
333            Self::Service(_) => "CallError::Service([REDACTED])",
334            Self::UnknownRemote(_) => "CallError::UnknownRemote([REDACTED])",
335            Self::Transport(_) => "CallError::Transport([REDACTED])",
336            Self::Protocol(_) => "CallError::Protocol([REDACTED])",
337            Self::Internal { .. } => "CallError::Internal([REDACTED])",
338        })
339    }
340}
341
342impl<E> Error for CallError<E>
343where
344    E: Send + Sync + 'static,
345{
346    fn source(&self) -> Option<&(dyn Error + 'static)> {
347        match self {
348            Self::Service(failure) => Some(&failure.envelope),
349            Self::UnknownRemote(error) => error.source.as_deref().map(|error| error as _),
350            Self::Transport(error) => Some(error.source.as_ref()),
351            Self::Protocol(_) => None,
352            Self::Internal { source, .. } => Some(source.as_ref()),
353        }
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use std::error::Error;
360    use std::io;
361
362    use uuid::Uuid;
363
364    use super::{CallError, ServiceFailure};
365    use crate::error::{ErrorEnvelope, ServiceError};
366
367    #[derive(Debug)]
368    struct TestError;
369
370    impl ServiceError for TestError {
371        fn to_envelope(&self, instance: String) -> ErrorEnvelope {
372            ErrorEnvelope::new("NOT_FOUND", "Test:NotFound", instance)
373        }
374
375        fn from_envelope(envelope: &ErrorEnvelope) -> Option<Self> {
376            (envelope.error_code == "NOT_FOUND" && envelope.error_name == "Test:NotFound")
377                .then_some(Self)
378        }
379    }
380
381    #[test]
382    fn hides_diagnostics_but_preserves_error_sources() {
383        let errors = [
384            CallError::<TestError>::transport(io::Error::other("secret")),
385            CallError::<TestError>::protocol("secret"),
386            CallError::<TestError>::internal(io::Error::other("secret")),
387        ];
388        for error in errors {
389            assert!(!error.to_string().contains("secret"));
390            assert!(!format!("{error:?}").contains("secret"));
391            if !matches!(error, CallError::Protocol(_)) {
392                assert_eq!(error.source().unwrap().to_string(), "secret");
393            }
394        }
395    }
396
397    #[test]
398    fn dependency_propagation_keeps_only_canonical_auth_failures() {
399        let denied = CallError::<TestError>::permission_denied("read", "resource");
400        let instance = denied.error_instance_id().unwrap().to_owned();
401        let propagated = CallError::<TestError>::from_dependency(denied);
402        let CallError::Service(failure) = propagated else {
403            panic!("permission denial was not propagated")
404        };
405        assert_eq!(failure.canonical_status(), 403);
406        assert_eq!(failure.status(), 403);
407        assert_eq!(failure.observed_status(), None);
408        assert_eq!(failure.envelope().error_instance_id, instance);
409        assert!(failure.declared().is_none());
410
411        let envelope = ErrorEnvelope::new(
412            "PERMISSION_DENIED",
413            "Default:PermissionDenied",
414            Uuid::now_v7().to_string(),
415        );
416        let remote = CallError::<TestError>::Service(ServiceFailure::remote(500, envelope));
417        let CallError::Service(failure) = CallError::<TestError>::from_dependency(remote) else {
418            panic!("canonical permission denial was not propagated")
419        };
420        assert_eq!(failure.status(), 403);
421        assert_eq!(failure.observed_status(), None);
422
423        let remote = CallError::<TestError>::unknown_remote(401, None, Vec::new(), false);
424        assert!(matches!(
425            CallError::<TestError>::from_dependency(remote),
426            CallError::Internal { .. }
427        ));
428    }
429}