Skip to main content

cortex/frontend/
catchers.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! HTTP error catchers: one consistent, **content-negotiated** error response across the whole
9//! surface, instead of Rocket's built-in default page.
10//!
11//! An agent (a request under `/api`, or one sending `Accept: application/json`) gets a JSON
12//! `{ "error", "status" }`; a human gets the themed HTML error page (`templates/error`). The status
13//! code is unchanged — only the body shape — so this is the error-path half of the symmetry
14//! contract (humans and agents get the same information in their native form).
15
16use rocket::http::Status;
17use rocket::request::Request;
18use rocket::response::{self, Responder};
19use rocket::serde::json::Json;
20use rocket::{Catcher, catch, catchers};
21use rocket_dyn_templates::{Template, context};
22use serde_json::json;
23
24/// A status + message rendered as JSON for agents and themed HTML for humans, the branch chosen
25/// from the request (a `/api` path or an `application/json` `Accept` ⇒ JSON).
26struct NegotiatedError {
27  status: Status,
28  message: &'static str,
29}
30
31impl<'r> Responder<'r, 'static> for NegotiatedError {
32  fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> {
33    let wants_json = request.uri().path().starts_with("/api")
34      || request
35        .headers()
36        .get_one("Accept")
37        .is_some_and(|accept| accept.contains("application/json"));
38    if wants_json {
39      let body = Json(json!({ "error": self.message, "status": self.status.code }));
40      (self.status, body).respond_to(request)
41    } else {
42      let global = json!({
43        "title": format!("{} · {}", self.status.code, self.message),
44        "description": self.message,
45      });
46      let page = Template::render(
47        "error",
48        context! { global, status: self.status.code, message: self.message },
49      );
50      (self.status, page).respond_to(request)
51    }
52  }
53}
54
55#[catch(400)]
56fn bad_request(_request: &Request) -> NegotiatedError {
57  NegotiatedError {
58    status: Status::BadRequest,
59    message: "Bad request",
60  }
61}
62
63#[catch(401)]
64fn unauthorized(_request: &Request) -> NegotiatedError {
65  NegotiatedError {
66    status: Status::Unauthorized,
67    message: "Unauthorized — a valid token is required",
68  }
69}
70
71#[catch(403)]
72fn forbidden(_request: &Request) -> NegotiatedError {
73  NegotiatedError {
74    status: Status::Forbidden,
75    message: "Forbidden — this action is not permitted (e.g. a protected init/import service)",
76  }
77}
78
79#[catch(404)]
80fn not_found(_request: &Request) -> NegotiatedError {
81  NegotiatedError {
82    status: Status::NotFound,
83    message: "Not found",
84  }
85}
86
87#[catch(409)]
88fn conflict(_request: &Request) -> NegotiatedError {
89  NegotiatedError {
90    status: Status::Conflict,
91    message: "Conflict — the resource already exists, or the run is busy (e.g. tasks in progress)",
92  }
93}
94
95#[catch(422)]
96fn unprocessable(_request: &Request) -> NegotiatedError {
97  NegotiatedError {
98    status: Status::UnprocessableEntity,
99    message: "Unprocessable request — check the submitted values (e.g. an unreadable path or an \
100              unknown severity/grouping)",
101  }
102}
103
104#[catch(500)]
105fn internal_error(_request: &Request) -> NegotiatedError {
106  NegotiatedError {
107    status: Status::InternalServerError,
108    message: "Internal server error",
109  }
110}
111
112#[catch(503)]
113fn unavailable(_request: &Request) -> NegotiatedError {
114  NegotiatedError {
115    status: Status::ServiceUnavailable,
116    message: "Service unavailable — try again shortly",
117  }
118}
119
120/// The catcher set to register on the Rocket instance (`server::mount_api_with`).
121pub fn catchers() -> Vec<Catcher> {
122  catchers![
123    bad_request,
124    unauthorized,
125    forbidden,
126    not_found,
127    conflict,
128    unprocessable,
129    internal_error,
130    unavailable
131  ]
132}