Skip to main content

fraiseql_error/
webhook.rs

1/// Errors that occur while receiving and validating inbound webhook requests.
2#[derive(Debug, thiserror::Error)]
3#[non_exhaustive]
4pub enum WebhookError {
5    /// The HMAC signature on the webhook payload does not match the expected
6    /// value for the shared secret.
7    ///
8    /// This can indicate an invalid secret, payload tampering, or a replay
9    /// from a different provider.
10    #[error("Invalid signature")]
11    InvalidSignature,
12
13    /// The expected signature header was absent from the incoming request.
14    #[error("Missing signature header: {header}")]
15    MissingSignature {
16        /// Name of the HTTP header that was expected but not present.
17        header: String,
18    },
19
20    /// The webhook timestamp is older than the configured replay-window,
21    /// indicating a replay attack or severe clock skew.
22    #[error("Timestamp too old: {age_seconds}s (max: {max_seconds}s)")]
23    TimestampExpired {
24        /// Age of the webhook event in seconds.
25        age_seconds: u64,
26        /// Maximum allowed age in seconds.
27        max_seconds: u64,
28    },
29
30    /// The webhook timestamp is further in the future than clock skew allows,
31    /// suggesting a pre-generated or tampered request.
32    #[error("Timestamp in future: {future_seconds}s")]
33    TimestampFuture {
34        /// Number of seconds the timestamp is ahead of the server clock.
35        future_seconds: u64,
36    },
37
38    /// An event with this identifier has already been successfully processed.
39    ///
40    /// The event should be acknowledged (2xx) and then discarded.
41    #[error("Duplicate event: {event_id}")]
42    DuplicateEvent {
43        /// Identifier of the duplicate event.
44        event_id: String,
45    },
46
47    /// The event's `type` field does not correspond to any registered handler.
48    #[error("Unknown event type: {event_type}")]
49    UnknownEvent {
50        /// The unrecognised event type string.
51        event_type: String,
52    },
53
54    /// A webhook was received from a provider that has not been configured
55    /// in `fraiseql.toml`.
56    #[error("Provider not configured: {provider}")]
57    ProviderNotConfigured {
58        /// Name of the unconfigured provider.
59        provider: String,
60    },
61
62    /// The webhook request body could not be parsed (invalid JSON, unexpected
63    /// schema, etc.).
64    ///
65    /// The raw error message is kept server-side and a generic response is
66    /// returned to the caller.
67    #[error("Payload parse error: {message}")]
68    PayloadError {
69        /// Description of the parse failure (server-side only).
70        message: String,
71    },
72
73    /// The idempotency check (deduplication store lookup or write) failed.
74    #[error("Idempotency check failed: {message}")]
75    IdempotencyError {
76        /// Description of the idempotency failure.
77        message: String,
78    },
79}
80
81impl WebhookError {
82    /// Returns a short, stable error code string suitable for API responses and
83    /// structured logging.
84    pub const fn error_code(&self) -> &'static str {
85        match self {
86            Self::InvalidSignature => "webhook_invalid_signature",
87            Self::MissingSignature { .. } => "webhook_missing_signature",
88            Self::TimestampExpired { .. } => "webhook_timestamp_expired",
89            Self::TimestampFuture { .. } => "webhook_timestamp_future",
90            Self::DuplicateEvent { .. } => "webhook_duplicate_event",
91            Self::UnknownEvent { .. } => "webhook_unknown_event",
92            Self::ProviderNotConfigured { .. } => "webhook_provider_not_configured",
93            Self::PayloadError { .. } => "webhook_payload_error",
94            Self::IdempotencyError { .. } => "webhook_idempotency_error",
95        }
96    }
97}