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
| 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 | |
| 8 | use crate::brand; |
| 9 | use serde_json::{json, Value}; |
| 10 | |
| 11 | #[derive(Debug)] |
| 12 | pub 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 | |
| 41 | pub type Result<T> = std::result::Result<T, Error>; |
| 42 | |
| 43 | impl 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 | |
| 180 | impl From<anyhow::Error> for Error { |
| 181 | fn from(e: anyhow::Error) -> Self { |
| 182 | Error::Internal(e) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | impl From<std::io::Error> for Error { |
| 187 | fn from(e: std::io::Error) -> Self { |
| 188 | Error::Internal(e.into()) |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | impl 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 | |
| 198 | impl 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)] |
| 208 | mod 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 | } |