Skip to main content

quantum_sdk/
error.rs

1use serde::Deserialize;
2use std::fmt;
3
4/// Result type alias for Quantum AI SDK operations.
5pub type Result<T> = std::result::Result<T, Error>;
6
7/// Error types returned by the Quantum AI SDK.
8#[derive(Debug)]
9pub enum Error {
10    /// The API returned a non-2xx status code.
11    Api(ApiError),
12    /// An HTTP transport error occurred.
13    Http(reqwest::Error),
14    /// A serialization or deserialization error occurred.
15    Json(serde_json::Error),
16    /// A WebSocket error occurred (realtime sessions).
17    WebSocket(tokio_tungstenite::tungstenite::Error),
18}
19
20impl fmt::Display for Error {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Error::Api(e) => write!(f, "{e}"),
24            Error::Http(e) => write!(f, "qai: http error: {e}"),
25            Error::Json(e) => write!(f, "qai: json error: {e}"),
26            Error::WebSocket(e) => write!(f, "qai: websocket error: {e}"),
27        }
28    }
29}
30
31impl std::error::Error for Error {
32    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
33        match self {
34            Error::Api(_) => None,
35            Error::Http(e) => Some(e),
36            Error::Json(e) => Some(e),
37            Error::WebSocket(e) => Some(e),
38        }
39    }
40}
41
42impl From<tokio_tungstenite::tungstenite::Error> for Error {
43    fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
44        Error::WebSocket(err)
45    }
46}
47
48impl From<reqwest::Error> for Error {
49    fn from(err: reqwest::Error) -> Self {
50        Error::Http(err)
51    }
52}
53
54impl From<serde_json::Error> for Error {
55    fn from(err: serde_json::Error) -> Self {
56        Error::Json(err)
57    }
58}
59
60/// An error returned by the Quantum AI API (non-2xx response).
61#[derive(Debug, Clone)]
62pub struct ApiError {
63    /// The HTTP status code from the response.
64    pub status_code: u16,
65    /// The error type from the API (e.g. "invalid_request", "rate_limit").
66    pub code: String,
67    /// The human-readable error description.
68    pub message: String,
69    /// The unique request identifier from the X-QAI-Request-Id header.
70    pub request_id: String,
71}
72
73impl fmt::Display for ApiError {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        if self.request_id.is_empty() {
76            write!(
77                f,
78                "qai: {} {}: {}",
79                self.status_code, self.code, self.message
80            )
81        } else {
82            write!(
83                f,
84                "qai: {} {}: {} (request_id={})",
85                self.status_code, self.code, self.message, self.request_id
86            )
87        }
88    }
89}
90
91impl std::error::Error for ApiError {}
92
93impl ApiError {
94    /// Returns true if this is a 429 rate limit response.
95    pub fn is_rate_limit(&self) -> bool {
96        self.status_code == 429
97    }
98
99    /// Returns true if this is a 401 or 403 authentication/authorization failure.
100    pub fn is_auth(&self) -> bool {
101        self.status_code == 401 || self.status_code == 403
102    }
103
104    /// Returns true if this is a 404 not found response.
105    pub fn is_not_found(&self) -> bool {
106        self.status_code == 404
107    }
108}
109
110/// Checks whether an error is a rate limit APIError.
111pub fn is_rate_limit_error(err: &Error) -> bool {
112    matches!(err, Error::Api(e) if e.is_rate_limit())
113}
114
115/// Checks whether an error is an authentication APIError.
116pub fn is_auth_error(err: &Error) -> bool {
117    matches!(err, Error::Api(e) if e.is_auth())
118}
119
120/// Checks whether an error is a not found APIError.
121pub fn is_not_found_error(err: &Error) -> bool {
122    matches!(err, Error::Api(e) if e.is_not_found())
123}
124
125/// Raw API error body envelope for JSON parsing.
126#[derive(Deserialize)]
127pub(crate) struct ApiErrorBody {
128    pub error: ApiErrorInner,
129}
130
131#[derive(Deserialize)]
132pub(crate) struct ApiErrorInner {
133    #[serde(default)]
134    pub message: String,
135    #[serde(default)]
136    pub code: String,
137    #[serde(rename = "type", default)]
138    pub error_type: String,
139}
140
141/// Strongly-typed view of the API's stable error-code taxonomy
142/// (`internal/server/errors.go` on the backend). Use this instead
143/// of substring-matching `ApiError::message` — the message text is
144/// human-readable and may change between releases; the code is
145/// part of the wire contract and never gets repurposed.
146///
147/// `Unknown` covers two cases: (a) the backend emitted a code this
148/// SDK version doesn't recognise yet (forward-compat — a new code
149/// shipped after the SDK was built), and (b) the backend response
150/// had no code field at all (legacy / non-canonical error path).
151/// In both cases the raw string is preserved on `ApiError::code` so
152/// callers can match on it if they need to.
153///
154/// Variant naming mirrors the Go constants 1:1 so a `grep` for
155/// `KEY_FROZEN_BY_BUDGET` finds matches across both repos.
156#[derive(Debug, Clone, PartialEq, Eq)]
157#[non_exhaustive]
158pub enum ErrorCode {
159    // Auth / identity
160    AuthHeaderMissing,
161    AuthHeaderEmpty,
162    KeyBearerMalformed,
163    KeyNotFound,
164    KeyExpired,
165    KeyRevokedByAdmin,
166    KeyRevokedByOwner,
167    /// Partner GCP budget kill-switch fired — distinguishable from
168    /// a self-revoke or admin-revoke because the user's account is
169    /// fine, the partner's billing isn't. Remediation: contact the
170    /// partner to top up.
171    KeyFrozenByBudget,
172    KeyPartnerRejected,
173    SessionExpired,
174    EphemeralExpired,
175
176    // Authz / scope
177    ScopeEndpointDenied,
178    AdminRequired,
179    ServiceAccountRequired,
180
181    // Billing / credits
182    InsufficientBalance,
183    TrialExpired,
184    SubscriptionLapsed,
185    SpendCapExceeded,
186    /// Runtime variant of partner budget freeze — fired mid-request
187    /// vs. KeyFrozenByBudget which fires at auth time.
188    BudgetFrozen,
189    PaymentNotConfigured,
190    BillingPortalNoHistory,
191
192    // Rate / quota
193    RateLimitedPerKey,
194    RateLimitedPerIP,
195    QuotaExceeded,
196
197    // Provider / upstream
198    ProviderRateLimited,
199    ProviderUnavailable,
200    ProviderAuthFailed,
201    ProviderInvalidRequest,
202    /// Moderation block. Framed as content, NOT as account-state —
203    /// the user can retry with different content.
204    ContentRejected,
205    ModelNotAvailable,
206
207    // Request shape / validation
208    InvalidRequestBody,
209    MissingRequiredField,
210    FieldTooLong,
211    InvalidAttachment,
212    AttachmentTooLarge,
213    UnsupportedCapability,
214
215    // System
216    InternalError,
217    ServiceUnavailable,
218    StripeApiError,
219    IdempotencyConflict,
220
221    // Per-product paywall codes
222    RecipeBoxPaywall,
223
224    /// Unrecognised code — either a newer-than-SDK code or a non-
225    /// canonical response with no code field. The raw string is on
226    /// `ApiError::code`.
227    Unknown,
228}
229
230impl ErrorCode {
231    /// Parse the wire code string into a typed variant. Unknown
232    /// strings (including empty) yield `ErrorCode::Unknown`. Match
233    /// is case-sensitive — the backend guarantees uppercase
234    /// snake_case for canonical codes.
235    pub fn from_wire(code: &str) -> Self {
236        match code {
237            "AUTH_HEADER_MISSING" => Self::AuthHeaderMissing,
238            "AUTH_HEADER_EMPTY" => Self::AuthHeaderEmpty,
239            "KEY_BEARER_MALFORMED" => Self::KeyBearerMalformed,
240            "KEY_NOT_FOUND" => Self::KeyNotFound,
241            "KEY_EXPIRED" => Self::KeyExpired,
242            "KEY_REVOKED_BY_ADMIN" => Self::KeyRevokedByAdmin,
243            "KEY_REVOKED_BY_OWNER" => Self::KeyRevokedByOwner,
244            "KEY_FROZEN_BY_BUDGET" => Self::KeyFrozenByBudget,
245            "KEY_PARTNER_REJECTED" => Self::KeyPartnerRejected,
246            "SESSION_EXPIRED" => Self::SessionExpired,
247            "EPHEMERAL_EXPIRED" => Self::EphemeralExpired,
248            "SCOPE_ENDPOINT_DENIED" => Self::ScopeEndpointDenied,
249            "ADMIN_REQUIRED" => Self::AdminRequired,
250            "SERVICE_ACCOUNT_REQUIRED" => Self::ServiceAccountRequired,
251            "INSUFFICIENT_BALANCE" => Self::InsufficientBalance,
252            "TRIAL_EXPIRED" => Self::TrialExpired,
253            "SUBSCRIPTION_LAPSED" => Self::SubscriptionLapsed,
254            "SPEND_CAP_EXCEEDED" => Self::SpendCapExceeded,
255            "BUDGET_FROZEN" => Self::BudgetFrozen,
256            "PAYMENT_NOT_CONFIGURED" => Self::PaymentNotConfigured,
257            "BILLING_PORTAL_NO_HISTORY" => Self::BillingPortalNoHistory,
258            "RATE_LIMITED_PER_KEY" => Self::RateLimitedPerKey,
259            "RATE_LIMITED_PER_IP" => Self::RateLimitedPerIP,
260            "QUOTA_EXCEEDED" => Self::QuotaExceeded,
261            "PROVIDER_RATE_LIMITED" => Self::ProviderRateLimited,
262            "PROVIDER_UNAVAILABLE" => Self::ProviderUnavailable,
263            "PROVIDER_AUTH_FAILED" => Self::ProviderAuthFailed,
264            "PROVIDER_INVALID_REQUEST" => Self::ProviderInvalidRequest,
265            "CONTENT_REJECTED" => Self::ContentRejected,
266            "MODEL_NOT_AVAILABLE" => Self::ModelNotAvailable,
267            "INVALID_REQUEST_BODY" => Self::InvalidRequestBody,
268            "MISSING_REQUIRED_FIELD" => Self::MissingRequiredField,
269            "FIELD_TOO_LONG" => Self::FieldTooLong,
270            "INVALID_ATTACHMENT" => Self::InvalidAttachment,
271            "ATTACHMENT_TOO_LARGE" => Self::AttachmentTooLarge,
272            "UNSUPPORTED_CAPABILITY" => Self::UnsupportedCapability,
273            "INTERNAL_ERROR" => Self::InternalError,
274            "SERVICE_UNAVAILABLE" => Self::ServiceUnavailable,
275            "STRIPE_API_ERROR" => Self::StripeApiError,
276            "IDEMPOTENCY_CONFLICT" => Self::IdempotencyConflict,
277            "RECIPE_BOX_PAYWALL" => Self::RecipeBoxPaywall,
278            _ => Self::Unknown,
279        }
280    }
281}
282
283impl ApiError {
284    /// Returns the strongly-typed error code. Convenience wrapper
285    /// over `ErrorCode::from_wire(&self.code)`.
286    pub fn typed_code(&self) -> ErrorCode {
287        ErrorCode::from_wire(&self.code)
288    }
289}