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::Selection(_) => Self::overloaded(message),
124 // Every oid passed to the object database here came from a ref this
125 // knot already resolved or a tree it already read, so a miss means
126 // the repository is missing an object it references. A handler that
127 // looks up something the caller named reports its own named error
128 // before it ever gets an oid.
129 GitError::ObjectNotFound(_)
130 | GitError::Open { .. }
131 | GitError::Create { .. }
132 | GitError::Remove { .. }
133 | GitError::Reference { .. }
134 | GitError::Fsync { .. }
135 | GitError::Write { .. }
136 | GitError::RevWalk(_)
137 | GitError::RemoveObject { .. }
138 | GitError::Corrupt { .. }
139 | GitError::ObjectType { .. }
140 | GitError::Decode(_)
141 | GitError::Backend(_)
142 | GitError::Staging(_)
143 | GitError::Config { .. }
144 | GitError::Maintenance(_) => Self::internal(message),
145 }
146 }
147}
148
149impl From<knot_git::ApplyError> for XrpcError {
150 fn from(error: knot_git::ApplyError) -> Self {
151 use knot_git::ApplyError;
152 match error {
153 ApplyError::TooLarge => Self::request_too_large(error.to_string()),
154 ApplyError::Git(inner) => inner.into(),
155 }
156 }
157}
158
159impl From<knot_cob::CobError> for XrpcError {
160 fn from(error: knot_cob::CobError) -> Self {
161 use knot_cob::CobError;
162 match error {
163 CobError::Contended(_) | CobError::StaleTip { .. } => Self::conflict(error.to_string()),
164 other => Self::internal(other.to_string()),
165 }
166 }
167}
168
169impl From<knot_index::IndexError> for XrpcError {
170 fn from(error: knot_index::IndexError) -> Self {
171 use knot_index::IndexError;
172 match error {
173 IndexError::Git(git) => git.into(),
174 IndexError::Cob(cob) => cob.into(),
175 other => Self::internal(other.to_string()),
176 }
177 }
178}
179
180impl From<knot_secrets::SecretsError> for XrpcError {
181 fn from(error: knot_secrets::SecretsError) -> Self {
182 use knot_secrets::SecretsError;
183 match error {
184 SecretsError::Occupied(_) => Self::conflict(error.to_string()),
185 other => Self::internal(other.to_string()),
186 }
187 }
188}
189
190impl From<knot_cobs::RegistryError> for XrpcError {
191 fn from(error: knot_cobs::RegistryError) -> Self {
192 use knot_cobs::RegistryError;
193 match error {
194 RegistryError::AlreadyRegistered { .. }
195 | RegistryError::RepoMismatch { .. }
196 | RegistryError::RkeyTaken { .. }
197 | RegistryError::OwnerMoved { .. } => Self::conflict(error.to_string()),
198 RegistryError::NotRegistered { .. } | RegistryError::NotHosted { .. } => {
199 Self::not_found(error.to_string())
200 }
201 RegistryError::Cob(cob) => cob.into(),
202 }
203 }
204}
205
206impl From<knot_atproto::AtprotoError> for XrpcError {
207 fn from(error: knot_atproto::AtprotoError) -> Self {
208 use knot_atproto::AtprotoError;
209 if error.is_transient() {
210 return Self::upstream_unavailable(error.to_string());
211 }
212 match error {
213 AtprotoError::Resolve(_) => Self::invalid_request(error.to_string()),
214 AtprotoError::PlcSubmit { .. } => Self::bad_gateway(error.to_string()),
215 other => Self::internal(other.to_string()),
216 }
217 }
218}
219
220impl From<knot_atproto::IdentityError> for XrpcError {
221 fn from(error: knot_atproto::IdentityError) -> Self {
222 Self::internal(error.to_string())
223 }
224}
225
226impl From<knot_pack::PackError> for XrpcError {
227 fn from(error: knot_pack::PackError) -> Self {
228 Self::from_status(error.http_status(), error.to_string())
229 }
230}
231
232impl From<knot_pack::FetchError> for XrpcError {
233 fn from(error: knot_pack::FetchError) -> Self {
234 use knot_pack::FetchError;
235 let message = error.to_string();
236 match error {
237 FetchError::Url(_) => Self::invalid_request(message),
238 FetchError::Network(_) => Self::upstream_unavailable(message),
239 FetchError::Status(_) | FetchError::Protocol(_) | FetchError::Remote(_) => {
240 Self::bad_gateway(message)
241 }
242 FetchError::PackTooLarge { .. } => Self::request_too_large(message),
243 FetchError::Pack(pack) => pack.into(),
244 }
245 }
246}
247
248impl IntoResponse for XrpcError {
249 fn into_response(self) -> Response {
250 match self.status {
251 StatusCode::FORBIDDEN | StatusCode::TOO_MANY_REQUESTS => tracing::warn!(
252 status = self.status.as_u16(),
253 error = self.error,
254 message = %self.message,
255 "request rejected"
256 ),
257 StatusCode::UNAUTHORIZED => tracing::debug!(
258 error = self.error,
259 message = %self.message,
260 "request unauthenticated"
261 ),
262 _ => {}
263 }
264 (
265 self.status,
266 Json(json!({ "error": self.error, "message": self.message })),
267 )
268 .into_response()
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::XrpcError;
275 use axum::response::IntoResponse;
276 use http::StatusCode;
277
278 #[test]
279 fn each_tag_maps_to_its_status_in_the_class_and_through_into_response() {
280 let cases: &[(XrpcError, StatusCode, &str)] = &[
281 (
282 XrpcError::invalid_request("x"),
283 StatusCode::BAD_REQUEST,
284 "InvalidRequest",
285 ),
286 (
287 XrpcError::auth_required("x"),
288 StatusCode::UNAUTHORIZED,
289 "AuthenticationRequired",
290 ),
291 (
292 XrpcError::forbidden("x"),
293 StatusCode::FORBIDDEN,
294 "Forbidden",
295 ),
296 (XrpcError::not_found("x"), StatusCode::NOT_FOUND, "NotFound"),
297 (XrpcError::conflict("x"), StatusCode::CONFLICT, "Conflict"),
298 (
299 XrpcError::request_too_large("x"),
300 StatusCode::PAYLOAD_TOO_LARGE,
301 "RequestTooLarge",
302 ),
303 (
304 XrpcError::rate_limited("x"),
305 StatusCode::TOO_MANY_REQUESTS,
306 "RateLimitExceeded",
307 ),
308 (
309 XrpcError::warming("x"),
310 StatusCode::SERVICE_UNAVAILABLE,
311 "ProjectionWarming",
312 ),
313 (
314 XrpcError::upstream_unavailable("x"),
315 StatusCode::SERVICE_UNAVAILABLE,
316 "UpstreamUnavailable",
317 ),
318 (
319 XrpcError::overloaded("x"),
320 StatusCode::SERVICE_UNAVAILABLE,
321 "Overloaded",
322 ),
323 (
324 XrpcError::bad_gateway("x"),
325 StatusCode::BAD_GATEWAY,
326 "UpstreamFailure",
327 ),
328 (
329 XrpcError::internal("x"),
330 StatusCode::INTERNAL_SERVER_ERROR,
331 "InternalError",
332 ),
333 ];
334 cases.iter().for_each(|(error, status, tag)| {
335 assert_eq!((error.status, error.error), (*status, *tag));
336 assert_eq!(
337 error.clone().into_response().status(),
338 *status,
339 "into_response serves the mapped status for {tag}"
340 );
341 });
342 }
343
344 #[test]
345 fn a_domain_error_maps_to_the_status_that_names_whose_fault_it_is() {
346 let oid = knot_types::Oid::from_hex(&"a".repeat(40)).unwrap();
347 let cases: Vec<(XrpcError, StatusCode, &str)> = vec![
348 (
349 knot_git::GitError::ObjectNotFound(oid).into(),
350 StatusCode::INTERNAL_SERVER_ERROR,
351 "these oids come from refs and trees this knot resolved itself, \
352 and every read lexicon names its own not-found error for what the caller asked for",
353 ),
354 (
355 knot_git::GitError::Corrupt {
356 oid,
357 message: "truncated".to_string(),
358 }
359 .into(),
360 StatusCode::INTERNAL_SERVER_ERROR,
361 "a corrupt repository on this knot isn't the caller's fault to fix",
362 ),
363 (
364 knot_git::GitError::AtomicRefs("lost".to_string()).into(),
365 StatusCode::CONFLICT,
366 "losing a ref race is a conflict",
367 ),
368 (
369 knot_git::GitError::AlreadyExists("/scallop".into()).into(),
370 StatusCode::CONFLICT,
371 "creating a repository that exists is a conflict",
372 ),
373 (
374 knot_pack::FetchError::Remote("gone".to_string()).into(),
375 StatusCode::BAD_GATEWAY,
376 "a fetch failure at the upstream during fork sync is the upstream's fault",
377 ),
378 (
379 knot_git::ApplyError::Git(knot_git::GitError::AtomicRefs("lost".to_string()))
380 .into(),
381 StatusCode::CONFLICT,
382 "wrapping a git error in ApplyError mustn't downgrade it to a generic fault",
383 ),
384 (
385 knot_index::IndexError::Git(knot_git::GitError::AlreadyExists("/whelk".into()))
386 .into(),
387 StatusCode::CONFLICT,
388 "wrapping a git error in IndexError mustn't downgrade it either",
389 ),
390 ];
391 cases.iter().for_each(|(error, status, why)| {
392 assert_eq!(error.status(), *status, "{why}");
393 });
394 }
395}