This repository has no description
1use axum::Json;
2use axum::response::{IntoResponse, Response};
3use http::StatusCode;
4use serde_json::json;
5
6#[derive(Debug, Clone)]
7pub struct XrpcError {
8 status: StatusCode,
9 error: &'static str,
10 message: String,
11}
12
13impl XrpcError {
14 fn new(status: StatusCode, error: &'static str, message: impl Into<String>) -> Self {
15 Self {
16 status,
17 error,
18 message: message.into(),
19 }
20 }
21
22 pub fn invalid_request(message: impl Into<String>) -> Self {
23 Self::new(StatusCode::BAD_REQUEST, "InvalidRequest", message)
24 }
25
26 pub(crate) fn named(
27 status: StatusCode,
28 error: &'static str,
29 message: impl Into<String>,
30 ) -> Self {
31 Self::new(status, error, message)
32 }
33
34 pub fn auth_required(message: impl Into<String>) -> Self {
35 Self::new(StatusCode::UNAUTHORIZED, "AuthenticationRequired", message)
36 }
37
38 pub fn forbidden(message: impl Into<String>) -> Self {
39 Self::new(StatusCode::FORBIDDEN, "Forbidden", message)
40 }
41
42 pub fn not_found(message: impl Into<String>) -> Self {
43 Self::new(StatusCode::NOT_FOUND, "NotFound", message)
44 }
45
46 pub fn conflict(message: impl Into<String>) -> Self {
47 Self::new(StatusCode::CONFLICT, "Conflict", message)
48 }
49
50 pub fn request_too_large(message: impl Into<String>) -> Self {
51 Self::new(StatusCode::PAYLOAD_TOO_LARGE, "RequestTooLarge", message)
52 }
53
54 pub fn warming(message: impl Into<String>) -> Self {
55 Self::new(
56 StatusCode::SERVICE_UNAVAILABLE,
57 "ProjectionWarming",
58 message,
59 )
60 }
61
62 pub fn upstream_unavailable(message: impl Into<String>) -> Self {
63 Self::new(
64 StatusCode::SERVICE_UNAVAILABLE,
65 "UpstreamUnavailable",
66 message,
67 )
68 }
69
70 pub fn rate_limited(message: impl Into<String>) -> Self {
71 Self::new(StatusCode::TOO_MANY_REQUESTS, "RateLimitExceeded", message)
72 }
73
74 pub fn overloaded(message: impl Into<String>) -> Self {
75 Self::new(StatusCode::SERVICE_UNAVAILABLE, "Overloaded", message)
76 }
77
78 pub fn bad_gateway(message: impl Into<String>) -> Self {
79 Self::new(StatusCode::BAD_GATEWAY, "UpstreamFailure", message)
80 }
81
82 pub fn internal(message: impl Into<String>) -> Self {
83 Self::new(StatusCode::INTERNAL_SERVER_ERROR, "InternalError", message)
84 }
85
86 pub(crate) fn status(&self) -> StatusCode {
87 self.status
88 }
89
90 pub(crate) fn from_status(status: StatusCode, message: impl Into<String>) -> Self {
91 let error = match status {
92 StatusCode::BAD_REQUEST => "InvalidRequest",
93 StatusCode::UNAUTHORIZED => "AuthenticationRequired",
94 StatusCode::FORBIDDEN => "Forbidden",
95 StatusCode::NOT_FOUND => "NotFound",
96 StatusCode::CONFLICT => "Conflict",
97 StatusCode::PAYLOAD_TOO_LARGE => "RequestTooLarge",
98 StatusCode::UNSUPPORTED_MEDIA_TYPE => "UnsupportedMediaType",
99 StatusCode::TOO_MANY_REQUESTS => "RateLimitExceeded",
100 StatusCode::SERVICE_UNAVAILABLE => "Overloaded",
101 StatusCode::BAD_GATEWAY => "UpstreamFailure",
102 _ => return Self::internal(message),
103 };
104 Self::new(status, error, message)
105 }
106}
107
108impl std::fmt::Display for XrpcError {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 write!(f, "{}: {}", self.error, self.message)
111 }
112}
113
114impl From<knot_git::GitError> for XrpcError {
115 fn from(error: knot_git::GitError) -> Self {
116 use knot_git::GitError;
117 let message = error.to_string();
118 match error {
119 GitError::AlreadyExists(_) => Self::conflict(message),
120 GitError::AtomicRefs(_) => Self::conflict(message),
121 GitError::UnsafeRepoDid(_) | GitError::ReservedDid(_) => Self::invalid_request(message),
122 GitError::DepthExceeded(_) => Self::invalid_request(message),
123 GitError::ArchiveTooLarge { .. } => Self::request_too_large(message),
124 GitError::Selection(_) => Self::overloaded(message),
125 // Every oid passed to the object database here came from a ref this
126 // knot already resolved or a tree it already read, so a miss means
127 // the repository is missing an object it references. A handler that
128 // looks up something the caller named reports its own named error
129 // before it ever gets an oid.
130 GitError::ObjectNotFound(_)
131 | GitError::Open { .. }
132 | GitError::Create { .. }
133 | GitError::Remove { .. }
134 | GitError::Reference { .. }
135 | GitError::Fsync { .. }
136 | GitError::Write { .. }
137 | GitError::RevWalk(_)
138 | GitError::RemoveObject { .. }
139 | GitError::Corrupt { .. }
140 | GitError::ObjectType { .. }
141 | GitError::Decode(_)
142 | GitError::Backend(_)
143 | GitError::Staging(_)
144 | GitError::Config { .. }
145 | GitError::Maintenance(_) => Self::internal(message),
146 }
147 }
148}
149
150impl From<knot_git::ApplyError> for XrpcError {
151 fn from(error: knot_git::ApplyError) -> Self {
152 use knot_git::ApplyError;
153 match error {
154 ApplyError::TooLarge => Self::request_too_large(error.to_string()),
155 ApplyError::Git(inner) => inner.into(),
156 }
157 }
158}
159
160impl From<knot_cob::CobError> for XrpcError {
161 fn from(error: knot_cob::CobError) -> Self {
162 use knot_cob::CobError;
163 match error {
164 CobError::Contended(_) | CobError::StaleTip { .. } => Self::conflict(error.to_string()),
165 other => Self::internal(other.to_string()),
166 }
167 }
168}
169
170impl From<knot_index::IndexError> for XrpcError {
171 fn from(error: knot_index::IndexError) -> Self {
172 use knot_index::IndexError;
173 match error {
174 IndexError::Git(git) => git.into(),
175 IndexError::Cob(cob) => cob.into(),
176 other => Self::internal(other.to_string()),
177 }
178 }
179}
180
181impl From<knot_secrets::SecretsError> for XrpcError {
182 fn from(error: knot_secrets::SecretsError) -> Self {
183 use knot_secrets::SecretsError;
184 match error {
185 SecretsError::Occupied(_) => Self::conflict(error.to_string()),
186 other => Self::internal(other.to_string()),
187 }
188 }
189}
190
191impl From<knot_cobs::RegistryError> for XrpcError {
192 fn from(error: knot_cobs::RegistryError) -> Self {
193 use knot_cobs::RegistryError;
194 match error {
195 RegistryError::AlreadyRegistered { .. }
196 | RegistryError::RepoMismatch { .. }
197 | RegistryError::RkeyTaken { .. }
198 | RegistryError::OwnerMoved { .. } => Self::conflict(error.to_string()),
199 RegistryError::NotRegistered { .. } | RegistryError::NotHosted { .. } => {
200 Self::not_found(error.to_string())
201 }
202 RegistryError::Cob(cob) => cob.into(),
203 }
204 }
205}
206
207impl From<knot_atproto::AtprotoError> for XrpcError {
208 fn from(error: knot_atproto::AtprotoError) -> Self {
209 use knot_atproto::AtprotoError;
210 if error.is_transient() {
211 return Self::upstream_unavailable(error.to_string());
212 }
213 match error {
214 AtprotoError::Resolve(_) => Self::invalid_request(error.to_string()),
215 AtprotoError::PlcSubmit { .. } => Self::bad_gateway(error.to_string()),
216 other => Self::internal(other.to_string()),
217 }
218 }
219}
220
221impl From<knot_atproto::IdentityError> for XrpcError {
222 fn from(error: knot_atproto::IdentityError) -> Self {
223 Self::internal(error.to_string())
224 }
225}
226
227impl From<knot_pack::PackError> for XrpcError {
228 fn from(error: knot_pack::PackError) -> Self {
229 Self::from_status(error.http_status(), error.to_string())
230 }
231}
232
233impl From<knot_pack::FetchError> for XrpcError {
234 fn from(error: knot_pack::FetchError) -> Self {
235 use knot_pack::FetchError;
236 let message = error.to_string();
237 match error {
238 FetchError::Url(_) => Self::invalid_request(message),
239 FetchError::Network(_) => Self::upstream_unavailable(message),
240 FetchError::Status(_) | FetchError::Protocol(_) | FetchError::Remote(_) => {
241 Self::bad_gateway(message)
242 }
243 FetchError::PackTooLarge { .. } => Self::request_too_large(message),
244 FetchError::Pack(pack) => pack.into(),
245 }
246 }
247}
248
249impl IntoResponse for XrpcError {
250 fn into_response(self) -> Response {
251 match self.status {
252 StatusCode::FORBIDDEN | StatusCode::TOO_MANY_REQUESTS => tracing::warn!(
253 status = self.status.as_u16(),
254 error = self.error,
255 message = %self.message,
256 "request rejected"
257 ),
258 StatusCode::UNAUTHORIZED => tracing::debug!(
259 error = self.error,
260 message = %self.message,
261 "request unauthenticated"
262 ),
263 _ => {}
264 }
265 (
266 self.status,
267 Json(json!({ "error": self.error, "message": self.message })),
268 )
269 .into_response()
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::XrpcError;
276 use axum::response::IntoResponse;
277 use http::StatusCode;
278
279 #[test]
280 fn each_tag_maps_to_its_status_in_the_class_and_through_into_response() {
281 let cases: &[(XrpcError, StatusCode, &str)] = &[
282 (
283 XrpcError::invalid_request("x"),
284 StatusCode::BAD_REQUEST,
285 "InvalidRequest",
286 ),
287 (
288 XrpcError::auth_required("x"),
289 StatusCode::UNAUTHORIZED,
290 "AuthenticationRequired",
291 ),
292 (
293 XrpcError::forbidden("x"),
294 StatusCode::FORBIDDEN,
295 "Forbidden",
296 ),
297 (XrpcError::not_found("x"), StatusCode::NOT_FOUND, "NotFound"),
298 (XrpcError::conflict("x"), StatusCode::CONFLICT, "Conflict"),
299 (
300 XrpcError::request_too_large("x"),
301 StatusCode::PAYLOAD_TOO_LARGE,
302 "RequestTooLarge",
303 ),
304 (
305 XrpcError::rate_limited("x"),
306 StatusCode::TOO_MANY_REQUESTS,
307 "RateLimitExceeded",
308 ),
309 (
310 XrpcError::warming("x"),
311 StatusCode::SERVICE_UNAVAILABLE,
312 "ProjectionWarming",
313 ),
314 (
315 XrpcError::upstream_unavailable("x"),
316 StatusCode::SERVICE_UNAVAILABLE,
317 "UpstreamUnavailable",
318 ),
319 (
320 XrpcError::overloaded("x"),
321 StatusCode::SERVICE_UNAVAILABLE,
322 "Overloaded",
323 ),
324 (
325 XrpcError::bad_gateway("x"),
326 StatusCode::BAD_GATEWAY,
327 "UpstreamFailure",
328 ),
329 (
330 XrpcError::internal("x"),
331 StatusCode::INTERNAL_SERVER_ERROR,
332 "InternalError",
333 ),
334 ];
335 cases.iter().for_each(|(error, status, tag)| {
336 assert_eq!((error.status, error.error), (*status, *tag));
337 assert_eq!(
338 error.clone().into_response().status(),
339 *status,
340 "into_response serves the mapped status for {tag}"
341 );
342 });
343 }
344
345 #[test]
346 fn a_domain_error_maps_to_the_status_that_names_whose_fault_it_is() {
347 let oid = knot_types::Oid::from_hex(&"a".repeat(40)).unwrap();
348 let cases: Vec<(XrpcError, StatusCode, &str)> = vec![
349 (
350 knot_git::GitError::ObjectNotFound(oid).into(),
351 StatusCode::INTERNAL_SERVER_ERROR,
352 "these oids come from refs and trees this knot resolved itself, \
353 and every read lexicon names its own not-found error for what the caller asked for",
354 ),
355 (
356 knot_git::GitError::Corrupt {
357 oid,
358 message: "truncated".to_string(),
359 }
360 .into(),
361 StatusCode::INTERNAL_SERVER_ERROR,
362 "a corrupt repository on this knot isn't the caller's fault to fix",
363 ),
364 (
365 knot_git::GitError::AtomicRefs("lost".to_string()).into(),
366 StatusCode::CONFLICT,
367 "losing a ref race is a conflict",
368 ),
369 (
370 knot_git::GitError::AlreadyExists("/scallop".into()).into(),
371 StatusCode::CONFLICT,
372 "creating a repository that exists is a conflict",
373 ),
374 (
375 knot_pack::FetchError::Remote("gone".to_string()).into(),
376 StatusCode::BAD_GATEWAY,
377 "a fetch failure at the upstream during fork sync is the upstream's fault",
378 ),
379 (
380 knot_git::ApplyError::Git(knot_git::GitError::AtomicRefs("lost".to_string()))
381 .into(),
382 StatusCode::CONFLICT,
383 "wrapping a git error in ApplyError mustn't downgrade it to a generic fault",
384 ),
385 (
386 knot_index::IndexError::Git(knot_git::GitError::AlreadyExists("/whelk".into()))
387 .into(),
388 StatusCode::CONFLICT,
389 "wrapping a git error in IndexError mustn't downgrade it either",
390 ),
391 ];
392 cases.iter().for_each(|(error, status, why)| {
393 assert_eq!(error.status(), *status, "{why}");
394 });
395 }
396}