Skip to main content

aviso_ffi/
error.rs

1// (C) Copyright 2024- ECMWF and individual contributors.
2//
3// This software is licensed under the terms of the Apache Licence Version 2.0
4// which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
5// In applying this licence, ECMWF does not waive the privileges and immunities
6// granted to it by virtue of its status as an intergovernmental organisation nor
7// does it submit to any jurisdiction.
8
9//! The structured error surface of the C ABI.
10//!
11//! Every fallible call returns an `AvisoOutcome` that either
12//! carries a success value or owns an error. The error is exposed to C as the
13//! `AvisoError` struct, whose `const char*` fields borrow from the owning
14//! outcome and stay valid until the outcome is freed.
15
16use std::ffi::{CString, c_char};
17use std::ptr;
18
19use aviso::ClientError;
20use aviso::watch::{TriggerError, TriggerKindLabel};
21
22use crate::outcome::AvisoOutcome;
23
24/// Discriminates an `AvisoError`.
25///
26/// The first ten kinds mirror the core `ClientError`; the rest are conditions
27/// the ABI itself reports: bad arguments, misuse, internal faults, a caught
28/// Rust panic, and an unmapped (future) core variant. The discriminants are
29/// fixed for ABI stability.
30#[repr(C)]
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum AvisoErrorKind {
33    /// Network-level failure (connect, TLS, DNS, partial read).
34    Transport = 0,
35    /// Server returned a non-success HTTP status.
36    Http = 1,
37    /// Authentication setup or credential resolution failed.
38    Auth = 2,
39    /// A response body could not be decoded.
40    Decode = 3,
41    /// A `CloudEvent` envelope carried a malformed event id.
42    MalformedEvent = 4,
43    /// A gap was detected in a watch stream.
44    HistoryGap = 5,
45    /// A fatal wire-protocol condition was observed on a watch stream.
46    StreamProtocol = 6,
47    /// Configuration-time error (invalid source, missing field).
48    Config = 7,
49    /// A persistent state-store operation failed.
50    StateStore = 8,
51    /// A required trigger failed after all retries.
52    Trigger = 9,
53    /// An argument was null, not UTF-8, or otherwise malformed.
54    InvalidInput = 10,
55    /// The call was used incorrectly (for example, a blocking call from a
56    /// runtime or callback thread).
57    InvalidUsage = 11,
58    /// An internal invariant was violated.
59    Internal = 12,
60    /// A Rust panic was caught at the boundary.
61    Panic = 13,
62    /// An unmapped core error variant (the core enum grew).
63    Unknown = 14,
64}
65
66/// C-visible error detail. The `const char*` fields borrow from the owning
67/// `AvisoOutcome` and are valid until it is freed. A pointer
68/// is null when the field does not apply (`request_id` when unknown,
69/// `trigger_kind` / `error_kind` unless `kind` is `AvisoErrorKind_Trigger`).
70#[repr(C)]
71pub struct AvisoError {
72    /// The error kind.
73    pub kind: AvisoErrorKind,
74    /// HTTP status when `kind` is `AvisoErrorKind_Http`, else `0`.
75    pub http_status: u16,
76    /// Human-readable message (never null). Credentials are never included;
77    /// server-supplied error text (such as an HTTP response body) may be.
78    pub message: *const c_char,
79    /// Server-supplied request id, or null when unknown.
80    pub request_id: *const c_char,
81    /// Trigger label when `kind` is `AvisoErrorKind_Trigger`, else null.
82    pub trigger_kind: *const c_char,
83    /// Trigger inner-error label when applicable, else null.
84    pub error_kind: *const c_char,
85}
86
87/// Owned backing for an `AvisoError`. The `CString` fields keep the heap
88/// buffers that `view`'s pointers reference alive for the outcome's lifetime.
89pub(crate) struct OutcomeError {
90    message: CString,
91    request_id: Option<CString>,
92    trigger_kind: Option<CString>,
93    error_kind: Option<CString>,
94    view: AvisoError,
95}
96
97impl OutcomeError {
98    pub(crate) fn build(
99        kind: AvisoErrorKind,
100        http_status: u16,
101        message: String,
102        request_id: Option<String>,
103        trigger_kind: Option<&str>,
104        error_kind: Option<String>,
105    ) -> Self {
106        let mut error = OutcomeError {
107            message: cstring_lossy(message),
108            request_id: request_id.map(cstring_lossy),
109            trigger_kind: trigger_kind.map(|s| cstring_lossy(s.to_string())),
110            error_kind: error_kind.map(cstring_lossy),
111            view: AvisoError {
112                kind,
113                http_status,
114                message: ptr::null(),
115                request_id: ptr::null(),
116                trigger_kind: ptr::null(),
117                error_kind: ptr::null(),
118            },
119        };
120        // The pointers reference the stable heap buffers owned by the CString
121        // fields, not this struct, so they survive the moves this value makes on
122        // its way into the owning outcome. The `view` field's own address only
123        // matters once the outcome is boxed, where it is then stable.
124        error.view.message = error.message.as_ptr();
125        error.view.request_id = opt_ptr(error.request_id.as_ref());
126        error.view.trigger_kind = opt_ptr(error.trigger_kind.as_ref());
127        error.view.error_kind = opt_ptr(error.error_kind.as_ref());
128        error
129    }
130
131    /// Pointer to the C-visible view, valid for the owner's lifetime.
132    pub(crate) fn view(&self) -> *const AvisoError {
133        &raw const self.view
134    }
135
136    pub(crate) fn kind(&self) -> AvisoErrorKind {
137        self.view.kind
138    }
139
140    pub(crate) fn http_status(&self) -> u16 {
141        self.view.http_status
142    }
143
144    pub(crate) fn message_string(&self) -> String {
145        self.message.to_string_lossy().into_owned()
146    }
147
148    pub(crate) fn request_id_string(&self) -> Option<String> {
149        self.request_id
150            .as_ref()
151            .map(|value| value.to_string_lossy().into_owned())
152    }
153
154    /// Wraps this error in an owned outcome and hands it to C as a raw pointer.
155    pub(crate) fn into_outcome(self) -> *mut AvisoOutcome {
156        AvisoOutcome::error(self).into_raw()
157    }
158}
159
160fn opt_ptr(s: Option<&CString>) -> *const c_char {
161    s.map_or(ptr::null(), |c| c.as_ptr())
162}
163
164/// Builds a `CString`, dropping any interior NUL bytes so construction never
165/// fails on an arbitrary message.
166pub(crate) fn cstring_lossy(s: String) -> CString {
167    let bytes: Vec<u8> = s.into_bytes().into_iter().filter(|b| *b != 0).collect();
168    CString::new(bytes).unwrap_or_default()
169}
170
171/// Maps a core `ClientError` onto an owned `OutcomeError`. The wildcard arm
172/// keeps the mapping total across the `#[non_exhaustive]` core enum.
173pub(crate) fn map_error(err: &ClientError) -> OutcomeError {
174    use AvisoErrorKind as K;
175    match err {
176        ClientError::Transport(inner) => {
177            OutcomeError::build(K::Transport, 0, inner.to_string(), None, None, None)
178        }
179        ClientError::Http {
180            status,
181            body,
182            request_id,
183        } => OutcomeError::build(
184            K::Http,
185            *status,
186            format!("http {status}: {body}"),
187            request_id.clone(),
188            None,
189            None,
190        ),
191        ClientError::Auth(message) => {
192            OutcomeError::build(K::Auth, 0, message.clone(), None, None, None)
193        }
194        ClientError::Decode(inner) => {
195            OutcomeError::build(K::Decode, 0, inner.to_string(), None, None, None)
196        }
197        ClientError::MalformedEvent(detail) => {
198            OutcomeError::build(K::MalformedEvent, 0, detail.clone(), None, None, None)
199        }
200        ClientError::HistoryGap { reason } => OutcomeError::build(
201            K::HistoryGap,
202            0,
203            format!("history gap: {reason:?}"),
204            None,
205            None,
206            None,
207        ),
208        ClientError::StreamProtocol {
209            message,
210            request_id,
211        } => OutcomeError::build(
212            K::StreamProtocol,
213            0,
214            message.clone(),
215            request_id.clone(),
216            None,
217            None,
218        ),
219        ClientError::Config(message) => {
220            OutcomeError::build(K::Config, 0, message.clone(), None, None, None)
221        }
222        ClientError::StateStore(inner) => {
223            OutcomeError::build(K::StateStore, 0, inner.to_string(), None, None, None)
224        }
225        ClientError::TriggerFailed { kind, source } => OutcomeError::build(
226            K::Trigger,
227            0,
228            err.to_string(),
229            None,
230            Some(trigger_kind_label(kind)),
231            Some(trigger_error_label(source).to_string()),
232        ),
233        _ => OutcomeError::build(K::Unknown, 0, err.to_string(), None, None, None),
234    }
235}
236
237/// Stable label for the trigger's inner failure. Wildcard arm covers the
238/// `#[non_exhaustive]` core enum.
239fn trigger_error_label(source: &TriggerError) -> &'static str {
240    match source {
241        TriggerError::Io(_) => "io",
242        TriggerError::Encode(_) => "encode",
243        #[cfg(unix)]
244        TriggerError::Command { .. } => "command",
245        TriggerError::Timeout(_) => "timeout",
246        TriggerError::Webhook { .. } => "webhook",
247        TriggerError::WebhookBuild { .. } => "webhook_build",
248        TriggerError::Template { .. } => "template",
249        _ => "unknown",
250    }
251}
252
253/// Stable label for a trigger kind. Wildcard arm covers the
254/// `#[non_exhaustive]` core enum.
255fn trigger_kind_label(label: &TriggerKindLabel) -> &'static str {
256    match label {
257        TriggerKindLabel::Echo => "echo",
258        TriggerKindLabel::Log { .. } => "log",
259        #[cfg(unix)]
260        TriggerKindLabel::Command => "command",
261        TriggerKindLabel::Webhook => "webhook",
262        TriggerKindLabel::Teams => "teams",
263        TriggerKindLabel::Post => "post",
264        _ => "unknown",
265    }
266}
267
268/// Stable lowercase label for an error kind, used in the per-item JSON the
269/// batch verb returns. The strings are part of that JSON's documented contract.
270pub(crate) fn kind_label(kind: AvisoErrorKind) -> &'static str {
271    match kind {
272        AvisoErrorKind::Transport => "transport",
273        AvisoErrorKind::Http => "http",
274        AvisoErrorKind::Auth => "auth",
275        AvisoErrorKind::Decode => "decode",
276        AvisoErrorKind::MalformedEvent => "malformed_event",
277        AvisoErrorKind::HistoryGap => "history_gap",
278        AvisoErrorKind::StreamProtocol => "stream_protocol",
279        AvisoErrorKind::Config => "config",
280        AvisoErrorKind::StateStore => "state_store",
281        AvisoErrorKind::Trigger => "trigger",
282        AvisoErrorKind::InvalidInput => "invalid_input",
283        AvisoErrorKind::InvalidUsage => "invalid_usage",
284        AvisoErrorKind::Internal => "internal",
285        AvisoErrorKind::Panic => "panic",
286        AvisoErrorKind::Unknown => "unknown",
287    }
288}
289
290pub(crate) fn invalid_input(message: &str) -> OutcomeError {
291    OutcomeError::build(
292        AvisoErrorKind::InvalidInput,
293        0,
294        message.to_string(),
295        None,
296        None,
297        None,
298    )
299}
300
301pub(crate) fn invalid_usage(message: &str) -> OutcomeError {
302    OutcomeError::build(
303        AvisoErrorKind::InvalidUsage,
304        0,
305        message.to_string(),
306        None,
307        None,
308        None,
309    )
310}
311
312pub(crate) fn internal(message: &str) -> OutcomeError {
313    OutcomeError::build(
314        AvisoErrorKind::Internal,
315        0,
316        message.to_string(),
317        None,
318        None,
319        None,
320    )
321}
322
323/// The error built when `std::panic::catch_unwind` traps a panic at the
324/// boundary.
325pub(crate) fn panic_error() -> OutcomeError {
326    OutcomeError::build(
327        AvisoErrorKind::Panic,
328        0,
329        "a Rust panic was caught at the FFI boundary".to_string(),
330        None,
331        None,
332        None,
333    )
334}
335
336/// The outcome handed back when `std::panic::catch_unwind` traps a panic at
337/// the boundary.
338pub(crate) fn panic_outcome() -> *mut AvisoOutcome {
339    panic_error().into_outcome()
340}