Skip to main content

max / makenotwork

21.9 KB · 612 lines History Blame Raw
1 //! Application error types and HTTP error response rendering
2
3 use askama::Template;
4 use axum::{
5 http::{HeaderValue, StatusCode},
6 response::{Html, IntoResponse, Response},
7 };
8
9 /// Stashed in response extensions by `AppError::into_response()` so that the
10 /// JSON-error middleware on `/api/...` routes can swap the HTML body for
11 /// `{"error": "..."}` without touching any handler code.
12 #[derive(Clone)]
13 pub struct ApiErrorMessage(pub String);
14
15 /// Payload carried by `AppError::Validation`. `summary` is the user-visible
16 /// banner message.
17 ///
18 /// Field-level attribution (marking a specific input `aria-invalid`) is NOT done
19 /// here: in a server-rendered, per-form app the error layer doesn't know which
20 /// template to re-render, so highlighting is wired per form at the handler that
21 /// owns the template (see the join wizard's account step, which marks its own
22 /// fields). An earlier `field`/`with_field` mechanism on this type was never read
23 /// by `IntoResponse` and had no call sites; it was removed rather than left as
24 /// dead code that implied a generic path that didn't exist.
25 #[derive(Debug, Clone, PartialEq, Eq)]
26 pub struct ValidationError {
27 pub summary: String,
28 }
29
30 impl ValidationError {
31 pub fn new(summary: impl Into<String>) -> Self {
32 Self {
33 summary: summary.into(),
34 }
35 }
36 }
37
38 impl std::fmt::Display for ValidationError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 f.write_str(&self.summary)
41 }
42 }
43
44 impl From<String> for ValidationError {
45 fn from(summary: String) -> Self {
46 ValidationError::new(summary)
47 }
48 }
49
50 impl From<&str> for ValidationError {
51 fn from(summary: &str) -> Self {
52 ValidationError::new(summary.to_string())
53 }
54 }
55
56 impl From<&String> for ValidationError {
57 fn from(summary: &String) -> Self {
58 ValidationError::new(summary.clone())
59 }
60 }
61
62 /// Application error type that can be converted into an HTTP response
63 #[derive(Debug, thiserror::Error)]
64 pub enum AppError {
65 #[error("Not found")]
66 NotFound,
67
68 #[error("Unauthorized")]
69 Unauthorized,
70
71 #[error("Forbidden")]
72 Forbidden,
73
74 #[error("Bad request: {0}")]
75 BadRequest(String),
76
77 #[error("Validation error: {0}")]
78 Validation(ValidationError),
79
80 #[error("Database error: {0}")]
81 Database(#[from] sqlx::Error),
82
83 #[error("Internal server error")]
84 Internal(#[from] anyhow::Error),
85
86 #[error("Storage error: {0}")]
87 Storage(String),
88
89 #[error("Invalid file type: {0}")]
90 InvalidFileType(String),
91
92 #[error("File too large: {0}")]
93 FileTooLarge(String),
94
95 #[error("File quarantined: {0}")]
96 MalwareDetected(String),
97
98 #[error("Service unavailable: {0}")]
99 ServiceUnavailable(String),
100
101 #[error("Conflict: {0}")]
102 Conflict(String),
103
104 #[error("Payment required: {0}")]
105 PaymentRequired(String),
106
107 #[error("Method not allowed")]
108 MethodNotAllowed,
109 }
110
111 impl AppError {
112 /// Construct a validation error with a summary message.
113 pub fn validation(summary: impl Into<ValidationError>) -> Self {
114 AppError::Validation(summary.into())
115 }
116
117 /// Static tag for Prometheus metric labels (e.g. `kind="database"`)
118 pub fn tag(&self) -> &'static str {
119 match self {
120 AppError::NotFound => "not_found",
121 AppError::Unauthorized => "unauthorized",
122 AppError::Forbidden => "forbidden",
123 AppError::BadRequest(_) => "bad_request",
124 AppError::Validation(_) => "validation",
125 AppError::Database(_) => "database",
126 AppError::Internal(_) => "internal",
127 AppError::Storage(_) => "storage",
128 AppError::InvalidFileType(_) => "invalid_file_type",
129 AppError::FileTooLarge(_) => "file_too_large",
130 AppError::MalwareDetected(_) => "malware_detected",
131 AppError::ServiceUnavailable(_) => "service_unavailable",
132 AppError::Conflict(_) => "conflict",
133 AppError::PaymentRequired(_) => "payment_required",
134 AppError::MethodNotAllowed => "method_not_allowed",
135 }
136 }
137
138 /// Get the HTTP status code for this error
139 pub fn status_code(&self) -> StatusCode {
140 match self {
141 AppError::NotFound => StatusCode::NOT_FOUND,
142 AppError::Unauthorized => StatusCode::UNAUTHORIZED,
143 AppError::Forbidden => StatusCode::FORBIDDEN,
144 AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
145 AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
146 AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
147 AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
148 AppError::Storage(_) => StatusCode::INTERNAL_SERVER_ERROR,
149 AppError::InvalidFileType(_) => StatusCode::BAD_REQUEST,
150 AppError::FileTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
151 AppError::MalwareDetected(_) => StatusCode::UNPROCESSABLE_ENTITY,
152 AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
153 AppError::Conflict(_) => StatusCode::CONFLICT,
154 AppError::PaymentRequired(_) => StatusCode::PAYMENT_REQUIRED,
155 AppError::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
156 }
157 }
158
159 /// Get a user-friendly message for this error
160 pub fn user_message(&self) -> String {
161 match self {
162 AppError::NotFound => "The page you're looking for doesn't exist.".to_string(),
163 AppError::Unauthorized => "You need to log in to access this page.".to_string(),
164 AppError::Forbidden => "You don't have permission to access this page.".to_string(),
165 AppError::BadRequest(msg) => msg.clone(),
166 AppError::Validation(v) => v.summary.clone(),
167 AppError::InvalidFileType(msg) => msg.clone(),
168 AppError::FileTooLarge(msg) => msg.clone(),
169 AppError::MalwareDetected(_) => {
170 "This file has been flagged by our security scanner and cannot be uploaded."
171 .to_string()
172 }
173 AppError::ServiceUnavailable(msg) => msg.clone(),
174 AppError::Conflict(msg) => msg.clone(),
175 AppError::PaymentRequired(msg) => msg.clone(),
176 AppError::MethodNotAllowed => {
177 "That address exists, but it does not accept this kind of request.".to_string()
178 }
179 AppError::Database(_) | AppError::Internal(_) | AppError::Storage(_) => {
180 "Something went wrong. Please try again later.".to_string()
181 }
182 }
183 }
184 }
185
186 impl IntoResponse for AppError {
187 fn into_response(self) -> Response {
188 let status = self.status_code();
189 let message = self.user_message();
190
191 // Increment error counter for Prometheus
192 metrics::counter!("http_errors_total", "kind" => self.tag()).increment(1);
193
194 // Log server errors with structured fields.
195 // The request_id and user_id are already in the parent tracing span
196 // (set by TraceLayer and AuthUser respectively), so they appear
197 // automatically in these log lines.
198 match &self {
199 AppError::Database(e) => {
200 tracing::error!(error.kind = "database", error.detail = ?e, "request failed");
201 }
202 AppError::Internal(e) => {
203 tracing::error!(error.kind = "internal", error.detail = ?e, "request failed");
204 }
205 AppError::Storage(e) => {
206 tracing::error!(error.kind = "storage", error.detail = %e, "request failed");
207 }
208 AppError::MalwareDetected(detail) => {
209 tracing::warn!(error.kind = "malware_detected", error.detail = %detail, "file quarantined");
210 }
211 _ => {}
212 }
213
214 let template = ErrorTemplate {
215 csrf_token: None, // Errors don't need CSRF token
216 status_code: status.as_u16(),
217 status_text: status.canonical_reason().unwrap_or("Error").to_string(),
218 message: message.clone(),
219 };
220
221 let mut response = match template.render() {
222 Ok(html) => (status, Html(html)).into_response(),
223 Err(_) => {
224 // Fallback if template rendering fails
225 (status, message.clone()).into_response()
226 }
227 };
228
229 // Stash the message so json_error_layer can swap HTML → JSON on API routes
230 response
231 .extensions_mut()
232 .insert(ApiErrorMessage(message.clone()));
233
234 // Also expose the message as an `HX-Error` header so the global HTMX error
235 // toast can surface it on EVERY route, not just /api routes where the body
236 // is parseable JSON, a page route returns an HTML error body the toast
237 // can't read, so it would otherwise show a generic string (Run 11 UX MINOR).
238 if let Ok(value) = HeaderValue::from_str(&header_safe(&message)) {
239 response.headers_mut().insert("HX-Error", value);
240 }
241
242 response
243 }
244 }
245
246 /// Router-wide handler for a request whose path matches a route but whose
247 /// method does not. Axum's built-in answer is a bare 405 with no body, which a
248 /// browser renders as its own network-error page: a GET of `/logout` (typed,
249 /// bookmarked or prefetched) showed the visitor nothing from the product. This
250 /// renders the branded error page instead. It changes only what the mismatch
251 /// renders; the route's own method set is untouched, so `/logout` stays
252 /// POST-plus-CSRF.
253 pub async fn method_not_allowed() -> Response {
254 AppError::MethodNotAllowed.into_response()
255 }
256
257 /// Reduce a user message to a single-line, visible-ASCII header value (header
258 /// values reject control bytes and non-ASCII). Truncated so a long validation
259 /// message can't bloat the response headers.
260 fn header_safe(message: &str) -> String {
261 message
262 .chars()
263 .map(|c| if (' '..='~').contains(&c) { c } else { ' ' })
264 .take(256)
265 .collect()
266 }
267
268 /// Error page template
269 #[derive(Template)]
270 #[template(path = "pages/error.html")]
271 pub struct ErrorTemplate {
272 pub csrf_token: Option<String>,
273 pub status_code: u16,
274 pub status_text: String,
275 pub message: String,
276 }
277
278 /// Result type alias for handlers
279 pub type Result<T> = std::result::Result<T, AppError>;
280
281 /// Extension trait for adding context to any `Result<T, E>` where `E` can
282 /// convert into `AppError`. The context string is preserved in the error chain
283 /// via `anyhow::Context`, making it visible in structured error logs.
284 ///
285 /// ```ignore
286 /// use crate::error::ResultExt;
287 /// let user = db::users::get_user_by_id(&db, id)
288 /// .await
289 /// .context("fetch user for checkout")?;
290 /// ```
291 pub trait ResultExt<T> {
292 fn context(self, msg: &'static str) -> Result<T>;
293 fn with_context<F: FnOnce() -> String>(self, f: F) -> Result<T>;
294 }
295
296 impl<T, E> ResultExt<T> for std::result::Result<T, E>
297 where
298 E: std::error::Error + Send + Sync + 'static,
299 {
300 fn context(self, msg: &'static str) -> Result<T> {
301 self.map_err(|e| AppError::Internal(anyhow::Error::new(e).context(msg)))
302 }
303
304 fn with_context<F: FnOnce() -> String>(self, f: F) -> Result<T> {
305 self.map_err(|e| AppError::Internal(anyhow::Error::new(e).context(f())))
306 }
307 }
308
309 #[cfg(test)]
310 mod tests {
311 use super::*;
312
313 #[test]
314 fn status_code_not_found() {
315 assert_eq!(AppError::NotFound.status_code(), StatusCode::NOT_FOUND);
316 }
317
318 #[test]
319 fn status_code_unauthorized() {
320 assert_eq!(
321 AppError::Unauthorized.status_code(),
322 StatusCode::UNAUTHORIZED
323 );
324 }
325
326 #[test]
327 fn status_code_bad_request() {
328 assert_eq!(
329 AppError::BadRequest("test".into()).status_code(),
330 StatusCode::BAD_REQUEST
331 );
332 }
333
334 #[test]
335 fn status_code_validation() {
336 assert_eq!(
337 AppError::validation("test").status_code(),
338 StatusCode::UNPROCESSABLE_ENTITY
339 );
340 }
341
342 #[test]
343 fn user_message_not_found() {
344 let msg = AppError::NotFound.user_message();
345 assert!(msg.contains("doesn't exist"));
346 }
347
348 #[test]
349 fn user_message_internal_is_safe() {
350 let msg = AppError::Storage("s3 connection refused".into()).user_message();
351 assert!(!msg.contains("s3")); // should not leak internal details
352 assert!(msg.contains("Something went wrong"));
353 }
354
355 #[test]
356 fn user_message_validation_passes_through() {
357 let msg = AppError::validation("Name too long").user_message();
358 assert_eq!(msg, "Name too long");
359 }
360
361 #[test]
362 fn status_code_file_too_large() {
363 assert_eq!(
364 AppError::FileTooLarge("too big".into()).status_code(),
365 StatusCode::PAYLOAD_TOO_LARGE
366 );
367 }
368
369 #[test]
370 fn api_error_message_clone() {
371 let msg = ApiErrorMessage("test error".to_string());
372 let cloned = msg.clone();
373 // Compare against the original, not a literal: that keeps `msg` alive so
374 // the clone is load-bearing. Asserting only the literal lets the clone be
375 // optimized away as redundant, and this test exists to exercise Clone.
376 assert_eq!(cloned.0, msg.0);
377 }
378
379 // ── IntoResponse rendering ──────────────────────────────────────────
380
381 fn response_status_and_body(err: AppError) -> (StatusCode, String, Option<ApiErrorMessage>) {
382 let response = err.into_response();
383 let status = response.status();
384 let api_msg = response.extensions().get::<ApiErrorMessage>().cloned();
385 // We can't easily extract the body synchronously, but we can verify
386 // the status and the stashed ApiErrorMessage extension.
387 (
388 status,
389 api_msg.as_ref().map(|m| m.0.clone()).unwrap_or_default(),
390 api_msg,
391 )
392 }
393
394 #[test]
395 fn into_response_not_found() {
396 let (status, body, ext) = response_status_and_body(AppError::NotFound);
397 assert_eq!(status, StatusCode::NOT_FOUND);
398 assert!(body.contains("doesn't exist"));
399 assert!(ext.is_some());
400 }
401
402 #[test]
403 fn into_response_unauthorized() {
404 let (status, body, _) = response_status_and_body(AppError::Unauthorized);
405 assert_eq!(status, StatusCode::UNAUTHORIZED);
406 assert!(body.contains("log in"));
407 }
408
409 #[test]
410 fn into_response_forbidden() {
411 let (status, body, _) = response_status_and_body(AppError::Forbidden);
412 assert_eq!(status, StatusCode::FORBIDDEN);
413 assert!(body.contains("permission"));
414 }
415
416 #[test]
417 fn into_response_bad_request() {
418 let (status, body, _) =
419 response_status_and_body(AppError::BadRequest("field required".into()));
420 assert_eq!(status, StatusCode::BAD_REQUEST);
421 assert_eq!(body, "field required");
422 }
423
424 #[test]
425 fn into_response_validation() {
426 let (status, body, _) = response_status_and_body(AppError::validation("too long"));
427 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
428 assert_eq!(body, "too long");
429 }
430
431 #[test]
432 fn into_response_invalid_file_type() {
433 let (status, body, _) =
434 response_status_and_body(AppError::InvalidFileType("not a PNG".into()));
435 assert_eq!(status, StatusCode::BAD_REQUEST);
436 assert_eq!(body, "not a PNG");
437 }
438
439 #[test]
440 fn into_response_file_too_large() {
441 let (status, body, _) =
442 response_status_and_body(AppError::FileTooLarge("over 500 MB".into()));
443 assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
444 assert_eq!(body, "over 500 MB");
445 }
446
447 #[test]
448 fn into_response_malware_detected() {
449 let (status, body, _) =
450 response_status_and_body(AppError::MalwareDetected("ClamAV:Eicar-Signature".into()));
451 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
452 assert!(body.contains("security scanner"));
453 assert!(
454 !body.contains("ClamAV"),
455 "internal scanner detail must not leak"
456 );
457 assert!(
458 !body.contains("Eicar"),
459 "internal signature name must not leak"
460 );
461 }
462
463 #[test]
464 fn into_response_service_unavailable() {
465 let (status, body, _) = response_status_and_body(AppError::ServiceUnavailable(
466 "try again in 5 minutes".into(),
467 ));
468 assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
469 assert_eq!(body, "try again in 5 minutes");
470 }
471
472 // ── Internal detail leakage ─────────────────────────────────────────
473
474 #[test]
475 fn into_response_internal_never_leaks_details() {
476 let inner = anyhow::anyhow!("pg connection pool exhausted on host db-primary:5432");
477 let (status, body, _) = response_status_and_body(AppError::Internal(inner));
478 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
479 assert!(body.contains("Something went wrong"));
480 assert!(
481 !body.contains("pg connection"),
482 "internal detail must not leak"
483 );
484 assert!(!body.contains("5432"), "host/port must not leak");
485 }
486
487 #[test]
488 fn into_response_database_never_leaks_details() {
489 let err = AppError::Database(sqlx::Error::PoolTimedOut);
490 let (status, body, _) = response_status_and_body(err);
491 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
492 assert!(body.contains("Something went wrong"));
493 assert!(!body.contains("PoolTimedOut"), "sqlx variant must not leak");
494 }
495
496 #[test]
497 fn into_response_storage_never_leaks_details() {
498 let (status, body, _) = response_status_and_body(AppError::Storage(
499 "S3 PutObject failed: AccessDenied on bucket mnw-prod".into(),
500 ));
501 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
502 assert!(body.contains("Something went wrong"));
503 assert!(!body.contains("S3"), "S3 detail must not leak");
504 assert!(!body.contains("mnw-prod"), "bucket name must not leak");
505 }
506
507 // ── ResultExt ───────────────────────────────────────────────────────
508
509 #[test]
510 fn result_ext_context_wraps_error() {
511 let original: std::result::Result<(), std::io::Error> = Err(std::io::Error::new(
512 std::io::ErrorKind::NotFound,
513 "file missing",
514 ));
515 let wrapped = original.context("loading config");
516 assert!(wrapped.is_err());
517 let app_err = wrapped.unwrap_err();
518 // Should produce an Internal variant
519 assert_eq!(app_err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
520 // The context string should appear in the Debug representation
521 let debug = format!("{app_err:?}");
522 assert!(
523 debug.contains("loading config"),
524 "context string should be in error chain"
525 );
526 }
527
528 #[test]
529 fn result_ext_with_context_wraps_error() {
530 let original: std::result::Result<(), std::io::Error> = Err(std::io::Error::other("boom"));
531 let wrapped = original.with_context(|| format!("processing item {}", 42));
532 assert!(wrapped.is_err());
533 let app_err = wrapped.unwrap_err();
534 assert_eq!(app_err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
535 let debug = format!("{app_err:?}");
536 assert!(debug.contains("processing item 42"));
537 }
538
539 #[test]
540 fn result_ext_ok_passes_through() {
541 let original: std::result::Result<i32, std::io::Error> = Ok(99);
542 let result = original.context("should not matter");
543 assert_eq!(result.unwrap(), 99);
544 }
545
546 // ── Tag coverage ────────────────────────────────────────────────────
547
548 #[test]
549 fn tag_matches_variant() {
550 assert_eq!(AppError::NotFound.tag(), "not_found");
551 assert_eq!(AppError::Unauthorized.tag(), "unauthorized");
552 assert_eq!(AppError::Forbidden.tag(), "forbidden");
553 assert_eq!(AppError::BadRequest("x".into()).tag(), "bad_request");
554 assert_eq!(AppError::validation("x").tag(), "validation");
555 assert_eq!(AppError::Storage("x".into()).tag(), "storage");
556 assert_eq!(
557 AppError::InvalidFileType("x".into()).tag(),
558 "invalid_file_type"
559 );
560 assert_eq!(AppError::FileTooLarge("x".into()).tag(), "file_too_large");
561 assert_eq!(
562 AppError::MalwareDetected("x".into()).tag(),
563 "malware_detected"
564 );
565 assert_eq!(
566 AppError::ServiceUnavailable("x".into()).tag(),
567 "service_unavailable"
568 );
569 assert_eq!(AppError::Internal(anyhow::anyhow!("x")).tag(), "internal");
570 }
571
572 // ── User message edge cases ─────────────────────────────────────────
573
574 #[test]
575 fn user_message_bad_request_preserves_content() {
576 let msg = AppError::BadRequest(String::new()).user_message();
577 assert_eq!(msg, ""); // empty input -> empty output (no crash)
578 }
579
580 #[test]
581 fn user_message_malware_detected_hides_detail() {
582 let msg = AppError::MalwareDetected("Win.Trojan.Agent-123456".into()).user_message();
583 assert!(!msg.contains("Win.Trojan"));
584 assert!(msg.contains("security scanner"));
585 }
586
587 #[test]
588 fn validation_error_from_string() {
589 let v: ValidationError = "boom".to_string().into();
590 assert_eq!(v.summary, "boom");
591 }
592
593 #[test]
594 fn validation_error_from_str() {
595 let v: ValidationError = "boom".into();
596 assert_eq!(v.summary, "boom");
597 }
598
599 #[test]
600 fn validation_constructor_accepts_plain_string() {
601 let e = AppError::validation("nope");
602 assert_eq!(e.tag(), "validation");
603 assert_eq!(e.user_message(), "nope");
604 }
605
606 #[test]
607 fn validation_constructor_accepts_owned_string() {
608 let e = AppError::validation("nope".to_string());
609 assert_eq!(e.user_message(), "nope");
610 }
611 }
612