zuka
zuka/src/error.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/error.rs
RSerror.rs7.8 KBDownload
1// Boundary error type. `anyhow` is used inside the crate; everything that reaches
2// HTTP converts to this, and this is the only place a status code is chosen.
3//
4// Wire format is RFC 9457 problem+json with a real `type` URI. Clients discriminate
5// on `type` and read extension members — never on `detail`, which is prose and may
6// change (SPEC §4.1).
7
8use crate::brand;
9use serde_json::{json, Value};
10
11#[derive(Debug)]
12pub enum Error {
13 NotFound(&'static str),
14 Conflict {
15 slug: &'static str,
16 detail: String,
17 current_sha: Option<String>,
18 },
19 Unauthorized,
20 Forbidden,
21 Invalid {
22 slug: &'static str,
23 detail: String,
24 },
25 Unprocessable(String),
26 MethodNotAllowed,
27 PayloadTooLarge {
28 limit: u64,
29 },
30 RateLimited {
31 retry_after: u32,
32 },
33 QuotaExceeded {
34 limit: u64,
35 used: u64,
36 },
37 StorageFull,
38 Internal(anyhow::Error),
39}
40
41pub type Result<T> = std::result::Result<T, Error>;
42
43impl Error {
44 pub fn invalid(slug: &'static str, detail: impl Into<String>) -> Self {
45 Error::Invalid {
46 slug,
47 detail: detail.into(),
48 }
49 }
50
51 pub fn conflict(slug: &'static str, detail: impl Into<String>) -> Self {
52 Error::Conflict {
53 slug,
54 detail: detail.into(),
55 current_sha: None,
56 }
57 }
58
59 pub fn status(&self) -> u16 {
60 match self {
61 Error::NotFound(_) => 404,
62 Error::Conflict { .. } => 409,
63 Error::Unauthorized => 401,
64 Error::Forbidden => 403,
65 Error::Invalid { .. } => 400,
66 Error::Unprocessable(_) => 422,
67 Error::MethodNotAllowed => 405,
68 Error::PayloadTooLarge { .. } => 413,
69 Error::RateLimited { .. } => 429,
70 Error::QuotaExceeded { .. } => 413,
71 Error::StorageFull => 507,
72 Error::Internal(_) => 500,
73 }
74 }
75
76 /// Stable slug forming the `type` URI. Part of the client contract.
77 pub fn slug(&self) -> &'static str {
78 match self {
79 Error::NotFound(_) => "not-found",
80 Error::Conflict { slug, .. } => slug,
81 Error::Unauthorized => "unauthorized",
82 Error::Forbidden => "forbidden",
83 Error::Invalid { slug, .. } => slug,
84 Error::Unprocessable(_) => "unprocessable",
85 Error::MethodNotAllowed => "method-not-allowed",
86 Error::PayloadTooLarge { .. } => "payload-too-large",
87 Error::RateLimited { .. } => "rate-limited",
88 Error::QuotaExceeded { .. } => "quota-exceeded",
89 Error::StorageFull => "storage-full",
90 Error::Internal(_) => "internal",
91 }
92 }
93
94 fn title(&self) -> &'static str {
95 match self {
96 Error::NotFound(what) => what,
97 Error::Conflict { .. } => "Conflict",
98 Error::Unauthorized => "Unauthorized",
99 Error::Forbidden => "Forbidden",
100 Error::Invalid { .. } => "Invalid request",
101 Error::Unprocessable(_) => "Unprocessable content",
102 Error::MethodNotAllowed => "Method not allowed",
103 Error::PayloadTooLarge { .. } => "Payload too large",
104 Error::RateLimited { .. } => "Rate limited",
105 Error::QuotaExceeded { .. } => "Quota exceeded",
106 Error::StorageFull => "Storage full",
107 Error::Internal(_) => "Internal error",
108 }
109 }
110
111 /// Human-readable prose. `Internal` deliberately returns a fixed string: the
112 /// cause is logged, never returned.
113 fn detail(&self) -> String {
114 match self {
115 Error::NotFound(what) => format!("no such {what}"),
116 Error::Conflict { detail, .. } => detail.clone(),
117 Error::Unauthorized => "a valid credential is required".into(),
118 Error::Forbidden => "this credential may not perform that operation".into(),
119 Error::Invalid { detail, .. } => detail.clone(),
120 Error::Unprocessable(detail) => detail.clone(),
121 Error::MethodNotAllowed => "that method is not allowed on this resource".into(),
122 Error::PayloadTooLarge { limit } => format!("body exceeds the {limit} byte limit"),
123 Error::RateLimited { .. } => "too many requests".into(),
124 Error::QuotaExceeded { limit, used } => {
125 format!("quota exceeded: {used} bytes used of {limit}")
126 }
127 Error::StorageFull => "the server is out of storage".into(),
128 Error::Internal(_) => "an internal error occurred".into(),
129 }
130 }
131
132 /// RFC 9457 body. Extension members carry the machine-readable payload.
133 pub fn problem(&self) -> Value {
134 let mut body = json!({
135 "type": brand::problem_uri(self.slug()),
136 "title": self.title(),
137 "status": self.status(),
138 "detail": self.detail(),
139 });
140 let map = body.as_object_mut().expect("problem body is an object");
141 match self {
142 Error::Conflict {
143 current_sha: Some(sha),
144 ..
145 } => {
146 map.insert("current_sha".into(), json!(sha));
147 }
148 Error::PayloadTooLarge { limit } => {
149 map.insert("limit".into(), json!(limit));
150 }
151 Error::RateLimited { retry_after } => {
152 map.insert("retry_after".into(), json!(retry_after));
153 }
154 Error::QuotaExceeded { limit, used } => {
155 map.insert("limit".into(), json!(limit));
156 map.insert("used".into(), json!(used));
157 }
158 _ => {}
159 }
160 body
161 }
162
163 /// Prose safe to print at a user's terminal. Never carries internal detail.
164 pub fn detail_for_user(&self) -> String {
165 match self {
166 Error::Internal(_) => "the server failed to handle this request".into(),
167 other => other.detail(),
168 }
169 }
170
171 /// The cause to log. `None` when the error carries no operator-only context.
172 pub fn cause(&self) -> Option<&anyhow::Error> {
173 match self {
174 Error::Internal(e) => Some(e),
175 _ => None,
176 }
177 }
178}
179
180impl From<anyhow::Error> for Error {
181 fn from(e: anyhow::Error) -> Self {
182 Error::Internal(e)
183 }
184}
185
186impl From<std::io::Error> for Error {
187 fn from(e: std::io::Error) -> Self {
188 Error::Internal(e.into())
189 }
190}
191
192impl std::error::Error for Error {
193 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
194 self.cause().map(|e| e.as_ref())
195 }
196}
197
198impl std::fmt::Display for Error {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 match self.cause() {
201 Some(cause) => write!(f, "{} ({}): {cause:#}", self.title(), self.slug()),
202 None => write!(f, "{} ({}): {}", self.title(), self.slug(), self.detail()),
203 }
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn problem_type_uri_is_built_from_the_slug() {
213 let problem = Error::NotFound("repository").problem();
214 assert_eq!(problem["type"], brand::problem_uri("not-found"));
215 assert_eq!(problem["status"], 404);
216 }
217
218 #[test]
219 fn internal_errors_never_leak_their_cause_to_the_client() {
220 let err = Error::Internal(anyhow::anyhow!("connection string with a password"));
221 let problem = err.problem();
222 let rendered = problem.to_string();
223 assert!(
224 !rendered.contains("password"),
225 "cause leaked into the problem body"
226 );
227 assert!(
228 err.cause().is_some(),
229 "cause must still be available for logging"
230 );
231 }
232
233 #[test]
234 fn payload_too_large_reports_the_limit_it_enforced() {
235 let problem = Error::PayloadTooLarge { limit: 4096 }.problem();
236 assert_eq!(problem["status"], 413);
237 assert_eq!(problem["limit"], 4096);
238 }
239}