crabllm_core/error.rs
1use serde::{Deserialize, Serialize};
2use std::time::Duration;
3
4/// Shared error type for the crabllm workspace.
5///
6/// Every variant carries enough structure that retry semantics
7/// ([`Error::is_transient`]), HTTP status ([`Error::http_status`]), and the
8/// client-facing error `type` ([`Error::kind`]) are derivable from the variant
9/// alone — never from string contents. Layers must NOT stringify-and-rewrap an
10/// inner error (`Error::Internal(format!("...: {e}"))`); propagate the inner
11/// `Error` or classify the cause into the right variant instead.
12#[derive(Debug, thiserror::Error)]
13pub enum Error {
14 /// TOML config parse error or missing env var. Startup only.
15 #[error("config error: {0}")]
16 Config(String),
17
18 /// Upstream provider returned (or streamed) an error. `body` is the
19 /// upstream's own message, passed through verbatim — never prefixed.
20 #[error("provider error (HTTP {status}): {body}")]
21 Provider {
22 status: u16,
23 body: String,
24 retry_after: Option<Duration>,
25 },
26
27 /// Transport failure talking to the upstream: connect, send, or reading a
28 /// (possibly mid-stream) response body. Transient.
29 #[error("network error: {0}")]
30 Network(String),
31
32 /// An upstream response body could not be deserialized into our types.
33 /// Not transient — retrying yields the same unparseable bytes.
34 #[error("decode error: {0}")]
35 Decode(String),
36
37 /// Our own request could not be serialized. A bug on our side, not the
38 /// upstream's. Not transient.
39 #[error("encode error: {0}")]
40 Encode(String),
41
42 /// The client's request is malformed or violates a precondition (e.g. a
43 /// body that doesn't match the schema, or an unsupported option
44 /// combination). The client's fault — not transient, 400.
45 #[error("invalid request: {0}")]
46 Invalid(String),
47
48 /// A provider does not implement the requested operation.
49 #[error("unsupported: {0}")]
50 Unsupported(String),
51
52 /// Request could not be routed: unknown model or no matching deployment.
53 #[error("routing error: {0}")]
54 Routing(String),
55
56 /// Request to upstream provider timed out.
57 #[error("request timed out")]
58 Timeout,
59
60 /// Genuine catch-all for internal bugs. Not transient — if it happens,
61 /// retrying won't help.
62 #[error("internal error: {0}")]
63 Internal(String),
64}
65
66impl Error {
67 /// Whether this error is transient and the request should be retried.
68 /// Only network failures, timeouts, and upstream 429/5xx are transient;
69 /// everything else (decode, encode, unsupported, routing, internal) is
70 /// deterministic and must not be retried.
71 pub fn is_transient(&self) -> bool {
72 match self {
73 Error::Provider { status, .. } => matches!(status, 429 | 500 | 502 | 503 | 504),
74 Error::Network(_) | Error::Timeout => true,
75 _ => false,
76 }
77 }
78
79 /// Extract the retry-after duration from a `Provider` error, if present.
80 pub fn retry_after(&self) -> Option<Duration> {
81 match self {
82 Error::Provider { retry_after, .. } => *retry_after,
83 _ => None,
84 }
85 }
86
87 /// HTTP status to return to the client for this error. Single source of
88 /// truth for the proxy's error-to-response mapping.
89 pub fn http_status(&self) -> u16 {
90 match self {
91 Error::Provider { status, .. } => *status,
92 Error::Network(_) | Error::Decode(_) => 502,
93 Error::Unsupported(_) => 501,
94 Error::Routing(_) => 404,
95 Error::Invalid(_) => 400,
96 Error::Timeout => 504,
97 Error::Config(_) | Error::Encode(_) | Error::Internal(_) => 500,
98 }
99 }
100
101 /// OpenAI-compatible error `type` for the client-facing `ApiError`.
102 pub fn kind(&self) -> &'static str {
103 match self {
104 Error::Provider { .. } => "upstream_error",
105 Error::Network(_) => "network_error",
106 Error::Decode(_) => "decode_error",
107 Error::Routing(_) | Error::Invalid(_) => "invalid_request_error",
108 Error::Unsupported(_) => "unsupported_error",
109 Error::Timeout => "timeout_error",
110 Error::Config(_) | Error::Encode(_) | Error::Internal(_) => "server_error",
111 }
112 }
113
114 /// Build an "operation not supported" error for a provider trait method
115 /// that has no implementation. Used by `Provider` trait default impls.
116 /// Distinct from per-provider rejection messages so log lines can be
117 /// disambiguated by grep.
118 pub fn not_implemented(method: &str) -> Self {
119 Error::Unsupported(format!("provider method '{method}' not implemented"))
120 }
121}
122
123#[cfg(feature = "gateway")]
124impl From<toml::de::Error> for Error {
125 fn from(e: toml::de::Error) -> Self {
126 Error::Config(e.to_string())
127 }
128}
129
130/// OpenAI-compatible error response returned to clients.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
133pub struct ApiError {
134 pub error: ApiErrorBody,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
139pub struct ApiErrorBody {
140 pub message: String,
141 #[serde(rename = "type")]
142 pub kind: String,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub param: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub code: Option<String>,
147}
148
149impl ApiError {
150 pub fn new(message: impl Into<String>, kind: impl Into<String>) -> Self {
151 ApiError {
152 error: ApiErrorBody {
153 message: message.into(),
154 kind: kind.into(),
155 param: None,
156 code: None,
157 },
158 }
159 }
160}