Skip to main content

rws_server/error_pages/
mod.rs

1//! Error pages (18.35.0, #95): what the client sees when a handler panics
2//! or the app returns an error, depending on [`RwsEnv`].
3//!
4//! - In `dev` and `test`: the panic message, where it happened, the request
5//!   and the backtrace — an HTML page for a browser, problem details with
6//!   `location` and `backtrace` members for anything else.
7//! - In `prod`: `500` problem details without any of it; the details go to
8//!   stderr.
9//!
10//! ```rust
11//! # extern crate rws_server as rust_web_server;
12//! use rust_web_server::app::App;
13//! use rust_web_server::error_pages::ErrorPages;
14//! use rust_web_server::response::Response;
15//! use rust_web_server::test_client::TestClient;
16//!
17//! let app = App::with_state(())
18//!     .get("/boom", |_, _, _, _| -> Response { panic!("the cache is empty") })
19//!     .wrap(ErrorPages::detailed(true));   // ErrorPages::from_env() reads RWS_ENV
20//! let response = TestClient::new(app).get("/boom").header("Accept", "text/html").send();
21//! assert_eq!(500, response.status());
22//! assert!(response.body_text().contains("the cache is empty"));
23//! ```
24//!
25//! With details on, every request goes through the layer on the blocking
26//! pool (async handlers included), so their panics can be caught; with them
27//! off, requests pass straight through and the server's own panic handling
28//! answers `500`.
29
30// The tests read the JSON bodies with serde_json.
31#[cfg(all(test, feature = "serde"))]
32mod tests;
33
34use std::panic::{catch_unwind, AssertUnwindSafe};
35use std::sync::atomic::{AtomicBool, Ordering};
36use std::sync::{Mutex, Once};
37
38use crate::application::Application;
39use crate::entry_point::RwsEnv;
40use crate::header::Header;
41use crate::middleware::Middleware;
42use crate::request::Request;
43use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
44use crate::server::ConnectionInfo;
45
46/// Where the last panic happened and its backtrace, recorded by the hook
47/// while a detailed `ErrorPages` exists.
48struct Captured {
49    location: String,
50    backtrace: String,
51}
52
53static CAPTURING: AtomicBool = AtomicBool::new(false);
54/// The latest panic of each thread (the newest few threads): a panic's
55/// details belong to the thread that caught it, whatever else panics
56/// meanwhile.
57static LAST: Mutex<Vec<(std::thread::ThreadId, Captured)>> = Mutex::new(Vec::new());
58const KEPT: usize = 64;
59
60/// Chain a panic hook (once) that records the location and backtrace of a
61/// panic while capturing is on, then runs the hook that was there.
62fn install_hook() {
63    static ONCE: Once = Once::new();
64    ONCE.call_once(|| {
65        let previous = std::panic::take_hook();
66        std::panic::set_hook(Box::new(move |info| {
67            if CAPTURING.load(Ordering::Relaxed) {
68                let location = info.location().map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())).unwrap_or_default();
69                let backtrace = std::backtrace::Backtrace::force_capture().to_string();
70                if let Ok(mut last) = LAST.lock() {
71                    let me = std::thread::current().id();
72                    last.retain(|(id, _)| *id != me);
73                    if last.len() >= KEPT {
74                        last.remove(0);
75                    }
76                    last.push((me, Captured { location, backtrace }));
77                }
78            }
79            previous(info);
80        }));
81    });
82}
83
84fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
85    if let Some(s) = payload.downcast_ref::<&str>() {
86        s.to_string()
87    } else if let Some(s) = payload.downcast_ref::<String>() {
88        s.clone()
89    } else {
90        "a panic with a non-string payload".to_string()
91    }
92}
93
94/// The error-page middleware. See the [module docs](self).
95#[derive(Clone, Copy, Debug)]
96pub struct ErrorPages {
97    details: bool,
98}
99
100impl ErrorPages {
101    /// Details per `RWS_ENV`: on in `dev` and `test`, off in `prod` (and
102    /// when it's unset).
103    pub fn from_env() -> Self {
104        Self::detailed(RwsEnv::current().shows_error_details())
105    }
106
107    /// Details on or off, whatever `RWS_ENV` says.
108    pub fn detailed(details: bool) -> Self {
109        if details {
110            install_hook();
111            CAPTURING.store(true, Ordering::Relaxed);
112        }
113        ErrorPages { details }
114    }
115
116    /// Whether the client sees the details.
117    pub fn shows_details(&self) -> bool {
118        self.details
119    }
120
121    fn answer(&self, request: &Request, message: &str, captured: Option<Captured>) -> Response {
122        let (location, backtrace) = captured.map(|c| (c.location, c.backtrace)).unwrap_or_default();
123        eprintln!("[rws] {} {} failed: {message}{}{}", request.method, request.request_uri, if location.is_empty() { String::new() } else { format!(" at {location}") }, if backtrace.is_empty() { String::new() } else { format!("\n{backtrace}") });
124        if !self.details {
125            return crate::error::problem::problem(STATUS_CODE_REASON_PHRASE.n500_internal_server_error, "");
126        }
127        let wants_html = request.header("accept").is_some_and(|h| h.value.contains("text/html"));
128        if wants_html {
129            return page(request, message, &location, &backtrace);
130        }
131        let frames: Vec<&str> = backtrace.lines().map(str::trim_end).filter(|l| !l.is_empty()).collect();
132        let body = serde_free_json(message, &location, &frames);
133        let content = crate::range::Range::get_content_range(body.into_bytes(), crate::error::problem::PROBLEM_JSON.to_string());
134        Response::get_response(STATUS_CODE_REASON_PHRASE.n500_internal_server_error, None, Some(vec![content]))
135    }
136}
137
138/// `{"type","title","status","detail","location","backtrace":[…]}` without
139/// a JSON crate.
140fn serde_free_json(message: &str, location: &str, frames: &[&str]) -> String {
141    fn s(text: &str) -> String {
142        let mut out = String::from("\"");
143        for c in text.chars() {
144            match c {
145                '"' => out.push_str("\\\""),
146                '\\' => out.push_str("\\\\"),
147                '\n' => out.push_str("\\n"),
148                '\r' => out.push_str("\\r"),
149                '\t' => out.push_str("\\t"),
150                c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
151                c => out.push(c),
152            }
153        }
154        out.push('"');
155        out
156    }
157    let frames: Vec<String> = frames.iter().map(|f| s(f)).collect();
158    format!(
159        "{{\"type\":\"about:blank\",\"title\":\"Internal Server Error\",\"status\":500,\"detail\":{},\"location\":{},\"backtrace\":[{}]}}",
160        s(message),
161        s(location),
162        frames.join(",")
163    )
164}
165
166fn escape(text: &str) -> String {
167    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
168}
169
170fn page(request: &Request, message: &str, location: &str, backtrace: &str) -> Response {
171    let headers: String = request
172        .headers
173        .iter()
174        .map(|h| {
175            let secret = matches!(h.name.to_ascii_lowercase().as_str(), "cookie" | "authorization" | "proxy-authorization" | "x-api-key");
176            let value = if secret { "(hidden)" } else { h.value.as_ref() };
177            format!("<tr><th>{}</th><td>{}</td></tr>", escape(&h.name), escape(value))
178        })
179        .collect();
180    let body = format!(
181        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>500: {title}</title>\
182         <style>body{{font:15px system-ui,sans-serif;margin:2rem;color:#222}}h1{{color:#b00020;font-size:1.4rem}}code,pre{{font:13px ui-monospace,monospace}}pre{{background:#f6f6f6;padding:1rem;overflow:auto}}th{{text-align:left;padding-right:1rem;vertical-align:top}}</style></head>\
183         <body><h1>{title}</h1><p><code>{location}</code></p><p><code>{method} {uri}</code></p>\
184         <h2>Backtrace</h2><pre>{backtrace}</pre><h2>Request headers</h2><table>{headers}</table>\
185         <p><small>Shown because RWS_ENV is dev or test; production answers a bare 500.</small></p></body></html>",
186        title = escape(message),
187        location = escape(location),
188        method = escape(&request.method),
189        uri = escape(&request.request_uri),
190        backtrace = if backtrace.is_empty() { "(none: the handler ran on another thread, or the app returned an error)".to_string() } else { escape(backtrace) },
191    );
192    let content = crate::range::Range::get_content_range(body.into_bytes(), crate::mime_type::MimeType::TEXT_HTML.to_string());
193    let mut response = Response::get_response(STATUS_CODE_REASON_PHRASE.n500_internal_server_error, None, Some(vec![content]));
194    response.headers.push(Header::new(Header::_CACHE_CONTROL, "no-store"));
195    response
196}
197
198impl Middleware for ErrorPages {
199    fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
200        match catch_unwind(AssertUnwindSafe(|| next.execute(request, connection))) {
201            Ok(Ok(response)) => Ok(response),
202            Ok(Err(message)) => Ok(self.answer(request, &message, None)),
203            Err(payload) => {
204                let me = std::thread::current().id();
205                let captured = LAST.lock().ok().and_then(|mut last| {
206                    let at = last.iter().position(|(id, _)| *id == me)?;
207                    Some(last.remove(at).1)
208                });
209                Ok(self.answer(request, &panic_message(&*payload), captured))
210            }
211        }
212    }
213
214    fn is_passthrough(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
215        !self.details
216    }
217
218    fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
219        true
220    }
221
222    fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
223        true
224    }
225
226    fn layer_name(&self) -> &'static str {
227        "ErrorPages"
228    }
229}