sui_id_shared/errors.rs
1//! Public-facing API error type.
2//!
3//! Internal causes are deliberately not embedded in the user-visible payload.
4//! Each error carries an opaque request id so that operators can correlate
5//! a user complaint with detailed server-side log entries without exposing
6//! internal information to the client.
7
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11/// Stable error code returned to API callers. The set is intentionally small
12/// so that integrators can branch on a finite alphabet rather than parsing
13/// human-readable messages.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ApiErrorCode {
17 /// The caller is not authenticated, or credentials are invalid.
18 Unauthorized,
19 /// The caller is authenticated but lacks permission for this operation.
20 Forbidden,
21 /// The request payload failed validation.
22 BadRequest,
23 /// The requested resource does not exist.
24 NotFound,
25 /// The request conflicts with current state (e.g. duplicate resource).
26 Conflict,
27 /// The system is in setup mode and the requested endpoint is not allowed
28 /// until initialization is complete (or the endpoint requires setup mode).
29 InvalidState,
30 /// Caller is being rate-limited.
31 TooManyRequests,
32 /// An OAuth 2.0 / OIDC protocol error. The exact `error` field follows
33 /// the relevant RFCs (e.g. `invalid_grant`, `invalid_client`).
34 Protocol,
35 /// Catch-all for anything we did not anticipate. The body deliberately
36 /// does not carry internal cause; correlate via `request_id`.
37 Internal,
38}
39
40impl ApiErrorCode {
41 /// HTTP status code commonly associated with this error.
42 pub const fn http_status(self) -> u16 {
43 match self {
44 Self::Unauthorized => 401,
45 Self::Forbidden => 403,
46 Self::BadRequest | Self::Protocol => 400,
47 Self::NotFound => 404,
48 Self::Conflict => 409,
49 Self::InvalidState => 409,
50 Self::TooManyRequests => 429,
51 Self::Internal => 500,
52 }
53 }
54}
55
56/// Wire-format error payload returned by the JSON API.
57#[derive(Debug, Clone, Serialize, Deserialize, Error)]
58#[error("{code:?}: {message}")]
59pub struct ApiError {
60 pub code: ApiErrorCode,
61 pub message: String,
62 /// Opaque correlation id. Always present; clients should surface this
63 /// when reporting a problem.
64 pub request_id: String,
65 /// Optional protocol-specific subcode, e.g. OAuth `error` field.
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub protocol_code: Option<String>,
68}
69
70impl ApiError {
71 pub fn new(code: ApiErrorCode, message: impl Into<String>, request_id: impl Into<String>) -> Self {
72 Self {
73 code,
74 message: message.into(),
75 request_id: request_id.into(),
76 protocol_code: None,
77 }
78 }
79
80 pub fn with_protocol_code(mut self, sub: impl Into<String>) -> Self {
81 self.protocol_code = Some(sub.into());
82 self
83 }
84}
85
86#[cfg(test)]
87mod tests;