reasonkit_web/stripe/
error.rs

1//! Stripe Webhook Error Types
2//!
3//! Comprehensive error handling for webhook processing with proper HTTP status mapping.
4
5use axum::{
6    http::StatusCode,
7    response::{IntoResponse, Response},
8    Json,
9};
10use serde::Serialize;
11use thiserror::Error;
12
13/// Stripe webhook error types with HTTP status code mapping
14#[derive(Error, Debug)]
15pub enum StripeWebhookError {
16    // =========================================================================
17    // Configuration Errors (500)
18    // =========================================================================
19    /// Missing webhook signing secret
20    #[error("STRIPE_WEBHOOK_SECRET environment variable not set")]
21    MissingSecret,
22
23    /// Invalid secret format
24    #[error("Invalid webhook secret format: {0}")]
25    InvalidSecretFormat(String),
26
27    // =========================================================================
28    // Signature Errors (400/401)
29    // =========================================================================
30    /// Missing stripe-signature header
31    #[error("Missing stripe-signature header")]
32    MissingSignature,
33
34    /// Invalid signature format
35    #[error("Invalid signature format: {0}")]
36    InvalidSignatureFormat(String),
37
38    /// Signature verification failed
39    #[error("Signature verification failed")]
40    SignatureVerificationFailed,
41
42    /// Timestamp too old (replay attack protection)
43    #[error("Webhook timestamp too old: {age_seconds}s (max: {max_age_seconds}s)")]
44    TimestampTooOld {
45        age_seconds: i64,
46        max_age_seconds: i64,
47    },
48
49    /// Timestamp in future (clock skew)
50    #[error("Webhook timestamp in future by {drift_seconds}s")]
51    TimestampInFuture { drift_seconds: i64 },
52
53    // =========================================================================
54    // Payload Errors (400)
55    // =========================================================================
56    /// Failed to parse request body
57    #[error("Failed to parse request body: {0}")]
58    InvalidPayload(String),
59
60    /// Unknown event type
61    #[error("Unknown event type: {0}")]
62    UnknownEventType(String),
63
64    /// Missing required field in event
65    #[error("Missing required field: {0}")]
66    MissingField(String),
67
68    // =========================================================================
69    // Idempotency (202 - not an error, but handled specially)
70    // =========================================================================
71    /// Event already processed (idempotent)
72    #[error("Event {event_id} already processed")]
73    AlreadyProcessed { event_id: String },
74
75    // =========================================================================
76    // Processing Errors (500)
77    // =========================================================================
78    /// Event processing failed
79    #[error("Event processing failed: {0}")]
80    ProcessingFailed(String),
81
82    /// Database error during processing
83    #[error("Database error: {0}")]
84    DatabaseError(String),
85
86    /// External service error
87    #[error("External service error: {0}")]
88    ExternalServiceError(String),
89
90    // =========================================================================
91    // Internal Errors (500)
92    // =========================================================================
93    /// Internal error
94    #[error("Internal error: {0}")]
95    InternalError(String),
96}
97
98/// Result type for Stripe webhook operations
99pub type StripeWebhookResult<T> = std::result::Result<T, StripeWebhookError>;
100
101impl StripeWebhookError {
102    /// Get the HTTP status code for this error
103    pub fn status_code(&self) -> StatusCode {
104        match self {
105            // 400 Bad Request - client sent malformed data
106            Self::InvalidPayload(_)
107            | Self::InvalidSignatureFormat(_)
108            | Self::UnknownEventType(_)
109            | Self::MissingField(_) => StatusCode::BAD_REQUEST,
110
111            // 401 Unauthorized - signature verification failed
112            Self::MissingSignature
113            | Self::SignatureVerificationFailed
114            | Self::TimestampTooOld { .. }
115            | Self::TimestampInFuture { .. } => StatusCode::UNAUTHORIZED,
116
117            // 202 Accepted - already processed (idempotent success)
118            Self::AlreadyProcessed { .. } => StatusCode::ACCEPTED,
119
120            // 500 Internal Server Error - our fault
121            Self::MissingSecret
122            | Self::InvalidSecretFormat(_)
123            | Self::ProcessingFailed(_)
124            | Self::DatabaseError(_)
125            | Self::ExternalServiceError(_)
126            | Self::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
127        }
128    }
129
130    /// Check if this error should be retried by Stripe
131    ///
132    /// Stripe will retry webhooks that return 5xx errors.
133    /// We return false for client errors (4xx) to prevent retries.
134    pub fn should_retry(&self) -> bool {
135        matches!(
136            self.status_code(),
137            StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE
138        )
139    }
140
141    /// Get error code for logging/metrics
142    pub fn error_code(&self) -> &'static str {
143        match self {
144            Self::MissingSecret => "MISSING_SECRET",
145            Self::InvalidSecretFormat(_) => "INVALID_SECRET_FORMAT",
146            Self::MissingSignature => "MISSING_SIGNATURE",
147            Self::InvalidSignatureFormat(_) => "INVALID_SIGNATURE_FORMAT",
148            Self::SignatureVerificationFailed => "SIGNATURE_VERIFICATION_FAILED",
149            Self::TimestampTooOld { .. } => "TIMESTAMP_TOO_OLD",
150            Self::TimestampInFuture { .. } => "TIMESTAMP_IN_FUTURE",
151            Self::InvalidPayload(_) => "INVALID_PAYLOAD",
152            Self::UnknownEventType(_) => "UNKNOWN_EVENT_TYPE",
153            Self::MissingField(_) => "MISSING_FIELD",
154            Self::AlreadyProcessed { .. } => "ALREADY_PROCESSED",
155            Self::ProcessingFailed(_) => "PROCESSING_FAILED",
156            Self::DatabaseError(_) => "DATABASE_ERROR",
157            Self::ExternalServiceError(_) => "EXTERNAL_SERVICE_ERROR",
158            Self::InternalError(_) => "INTERNAL_ERROR",
159        }
160    }
161}
162
163/// Error response body for API clients
164#[derive(Debug, Clone, Serialize)]
165pub struct ErrorResponse {
166    pub error: ErrorDetails,
167}
168
169#[derive(Debug, Clone, Serialize)]
170pub struct ErrorDetails {
171    pub code: String,
172    pub message: String,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub retry_after: Option<u64>,
175}
176
177impl IntoResponse for StripeWebhookError {
178    fn into_response(self) -> Response {
179        let status = self.status_code();
180        let error_code = self.error_code().to_string();
181
182        // For security, don't expose internal error details
183        let message = match &self {
184            // Safe to expose
185            Self::MissingSignature => self.to_string(),
186            Self::InvalidSignatureFormat(_) => "Invalid signature format".to_string(),
187            Self::SignatureVerificationFailed => self.to_string(),
188            Self::TimestampTooOld { .. } | Self::TimestampInFuture { .. } => {
189                "Webhook timestamp validation failed".to_string()
190            }
191            Self::InvalidPayload(_) => "Invalid request payload".to_string(),
192            Self::UnknownEventType(t) => format!("Unknown event type: {}", t),
193            Self::MissingField(f) => format!("Missing required field: {}", f),
194            Self::AlreadyProcessed { event_id } => {
195                format!("Event {} already processed", event_id)
196            }
197            // Internal errors - generic message
198            Self::MissingSecret
199            | Self::InvalidSecretFormat(_)
200            | Self::ProcessingFailed(_)
201            | Self::DatabaseError(_)
202            | Self::ExternalServiceError(_)
203            | Self::InternalError(_) => "Internal server error".to_string(),
204        };
205
206        let body = ErrorResponse {
207            error: ErrorDetails {
208                code: error_code,
209                message,
210                retry_after: if self.should_retry() {
211                    Some(60) // Suggest retry after 60 seconds
212                } else {
213                    None
214                },
215            },
216        };
217
218        (status, Json(body)).into_response()
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_error_status_codes() {
228        assert_eq!(
229            StripeWebhookError::MissingSignature.status_code(),
230            StatusCode::UNAUTHORIZED
231        );
232        assert_eq!(
233            StripeWebhookError::InvalidPayload("test".to_string()).status_code(),
234            StatusCode::BAD_REQUEST
235        );
236        assert_eq!(
237            StripeWebhookError::AlreadyProcessed {
238                event_id: "evt_123".to_string()
239            }
240            .status_code(),
241            StatusCode::ACCEPTED
242        );
243        assert_eq!(
244            StripeWebhookError::ProcessingFailed("test".to_string()).status_code(),
245            StatusCode::INTERNAL_SERVER_ERROR
246        );
247    }
248
249    #[test]
250    fn test_should_retry() {
251        assert!(!StripeWebhookError::MissingSignature.should_retry());
252        assert!(!StripeWebhookError::InvalidPayload("test".to_string()).should_retry());
253        assert!(StripeWebhookError::ProcessingFailed("test".to_string()).should_retry());
254        assert!(StripeWebhookError::DatabaseError("test".to_string()).should_retry());
255    }
256
257    #[test]
258    fn test_error_codes() {
259        assert_eq!(
260            StripeWebhookError::MissingSignature.error_code(),
261            "MISSING_SIGNATURE"
262        );
263        assert_eq!(
264            StripeWebhookError::SignatureVerificationFailed.error_code(),
265            "SIGNATURE_VERIFICATION_FAILED"
266        );
267    }
268}