1use std::collections::BTreeMap;
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ErrorCode {
20 InvalidRequest,
23 MultiQueryLimitExceeded,
25 Unauthorized,
27 Forbidden,
29 NotFound,
31 EmbeddingModelMismatch,
33 RunAborted,
35 RateLimited,
37 Internal,
40 ServiceUnavailable,
43 CloudUnreachable,
45 ModelsMissing,
48 TelemetrySchemaInvalid,
50}
51
52impl ErrorCode {
53 #[must_use]
55 pub const fn as_str(self) -> &'static str {
56 match self {
57 Self::InvalidRequest => "invalid_request",
58 Self::MultiQueryLimitExceeded => "multi_query_limit_exceeded",
59 Self::Unauthorized => "unauthorized",
60 Self::Forbidden => "forbidden",
61 Self::NotFound => "not_found",
62 Self::EmbeddingModelMismatch => "embedding_model_mismatch",
63 Self::RunAborted => "run_aborted",
64 Self::RateLimited => "rate_limited",
65 Self::Internal => "internal",
66 Self::ServiceUnavailable => "service_unavailable",
67 Self::CloudUnreachable => "cloud_unreachable",
68 Self::ModelsMissing => "models_missing",
69 Self::TelemetrySchemaInvalid => "telemetry_schema_invalid",
70 }
71 }
72
73 #[must_use]
75 pub const fn http_status(self) -> u16 {
76 match self {
77 Self::InvalidRequest | Self::MultiQueryLimitExceeded => 400,
78 Self::Unauthorized => 401,
79 Self::Forbidden => 403,
80 Self::NotFound => 404,
81 Self::EmbeddingModelMismatch | Self::RunAborted => 409,
82 Self::RateLimited => 429,
83 Self::ServiceUnavailable | Self::CloudUnreachable | Self::ModelsMissing => 503,
84 Self::Internal | Self::TelemetrySchemaInvalid => 500,
85 }
86 }
87}
88
89impl fmt::Display for ErrorCode {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.write_str(self.as_str())
92 }
93}
94
95pub type ErrorContext = BTreeMap<String, serde_json::Value>;
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109pub struct Error {
110 pub code: ErrorCode,
112 pub message: String,
114 pub remediation: String,
116 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
118 pub context: ErrorContext,
119}
120
121impl Error {
122 #[must_use]
124 pub const fn builder(code: ErrorCode) -> ErrorBuilder {
125 ErrorBuilder {
126 code,
127 message: None,
128 remediation: None,
129 context: ErrorContext::new(),
130 }
131 }
132
133 #[must_use]
135 pub const fn http_status(&self) -> u16 {
136 self.code.http_status()
137 }
138}
139
140impl fmt::Display for Error {
141 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142 write!(f, "{}: {}", self.code, self.message)
143 }
144}
145
146impl std::error::Error for Error {}
147
148#[derive(Debug)]
151pub struct ErrorBuilder {
152 code: ErrorCode,
153 message: Option<String>,
154 remediation: Option<String>,
155 context: ErrorContext,
156}
157
158impl ErrorBuilder {
159 #[must_use]
161 pub fn message(mut self, msg: impl Into<String>) -> Self {
162 self.message = Some(msg.into());
163 self
164 }
165
166 #[must_use]
168 pub fn remediation(mut self, rem: impl Into<String>) -> Self {
169 self.remediation = Some(rem.into());
170 self
171 }
172
173 #[must_use]
175 pub fn context(mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
176 self.context.insert(key.into(), value.into());
177 self
178 }
179
180 #[must_use]
187 pub fn build(self) -> Error {
188 Error {
189 code: self.code,
190 message: self
191 .message
192 .expect("Error::builder requires .message() — every error must have a summary"),
193 remediation: self.remediation.expect(
194 "Error::builder requires .remediation() — every error must point at a next step",
195 ),
196 context: self.context,
197 }
198 }
199}
200
201pub type Result<T> = std::result::Result<T, Error>;
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn code_round_trips_through_json() {
210 for code in [
211 ErrorCode::InvalidRequest,
212 ErrorCode::EmbeddingModelMismatch,
213 ErrorCode::RateLimited,
214 ErrorCode::ModelsMissing,
215 ] {
216 let s = serde_json::to_string(&code).unwrap();
217 let back: ErrorCode = serde_json::from_str(&s).unwrap();
218 assert_eq!(code, back);
219 }
220 }
221
222 #[test]
223 fn wire_shape_is_stable() {
224 let err = Error::builder(ErrorCode::EmbeddingModelMismatch)
225 .message("client model bge-small@1 does not match corpus model bge-base@1")
226 .remediation("run `mnm models pull` to fetch bge-base@1")
227 .context("corpus_model", "bge-base-en-v1.5@1")
228 .context("client_model", "bge-small-en-v1.5@1")
229 .build();
230
231 let v: serde_json::Value = serde_json::to_value(&err).unwrap();
232 assert_eq!(v["code"], "embedding_model_mismatch");
233 assert!(v["message"].is_string());
234 assert!(v["remediation"].is_string());
235 assert_eq!(v["context"]["corpus_model"], "bge-base-en-v1.5@1");
236 }
237
238 #[test]
239 fn http_status_mapping() {
240 assert_eq!(ErrorCode::InvalidRequest.http_status(), 400);
241 assert_eq!(ErrorCode::Unauthorized.http_status(), 401);
242 assert_eq!(ErrorCode::EmbeddingModelMismatch.http_status(), 409);
243 assert_eq!(ErrorCode::RateLimited.http_status(), 429);
244 assert_eq!(ErrorCode::ServiceUnavailable.http_status(), 503);
245 assert_eq!(ErrorCode::Internal.http_status(), 500);
246 }
247
248 #[test]
249 #[should_panic(expected = "requires .message()")]
250 fn builder_panics_without_message() {
251 let _ = Error::builder(ErrorCode::Internal)
252 .remediation("file an issue")
253 .build();
254 }
255
256 #[test]
257 #[should_panic(expected = "requires .remediation()")]
258 fn builder_panics_without_remediation() {
259 let _ = Error::builder(ErrorCode::Internal)
260 .message("something broke")
261 .build();
262 }
263
264 #[test]
265 fn empty_context_is_elided_from_wire() {
266 let err = Error::builder(ErrorCode::NotFound)
267 .message("source not found")
268 .remediation("check `mnm sources list`")
269 .build();
270 let v: serde_json::Value = serde_json::to_value(&err).unwrap();
271 assert!(v.get("context").is_none(), "empty context must be elided");
272 }
273}