Skip to main content

reinhardt_dispatch/
exception.rs

1//! Exception handling and conversion for HTTP requests
2//!
3//! This module provides functionality to convert exceptions into HTTP responses,
4//! similar to Django's `django.core.handlers.exception`.
5
6use async_trait::async_trait;
7use bytes::Bytes;
8use hyper::StatusCode;
9use reinhardt_http::{ExceptionHandler as HttpExceptionHandler, Request, Response};
10use std::fmt;
11use std::future::Future;
12use std::sync::Arc;
13use tracing::{error, warn};
14
15use crate::DispatchError;
16use crate::build_error_response;
17
18/// Result type for exception handlers
19pub type ExceptionResult = Result<Response, DispatchError>;
20
21/// A compatibility hook for handling the dispatch-specific error categories.
22///
23/// This trait preserves the public contract exposed by `reinhardt-dispatch`
24/// before the framework-wide HTTP exception hook was introduced. New server,
25/// router, and middleware APIs use [`reinhardt_http::ExceptionHandler`]; pass a
26/// legacy implementation through [`adapt_exception_handler`] when it must be
27/// installed through one of those APIs.
28#[async_trait]
29pub trait ExceptionHandler: Send + Sync {
30	/// Handle a dispatch error and convert it to a response.
31	async fn handle_exception(&self, request: &Request, error: DispatchError) -> Response;
32}
33
34/// Convert an internal dispatch error into the unified framework error used by
35/// [`HttpExceptionHandler`].
36pub(crate) fn dispatch_error_to_exception(
37	error: DispatchError,
38) -> reinhardt_core::exception::Error {
39	match error {
40		DispatchError::Middleware(message)
41		| DispatchError::View(message)
42		| DispatchError::Internal(message) => reinhardt_core::exception::Error::Internal(message),
43		DispatchError::UrlResolution(message) => {
44			reinhardt_core::exception::Error::NotFound(message)
45		}
46		DispatchError::Http(message) => reinhardt_core::exception::Error::Http(message),
47	}
48}
49
50/// Convert a framework error into the legacy dispatch error categories used by
51/// [`BaseHandler::handle_request`](crate::BaseHandler::handle_request).
52pub(crate) fn exception_to_dispatch_error(
53	error: reinhardt_core::exception::Error,
54) -> DispatchError {
55	match error {
56		reinhardt_core::exception::Error::NotFound(message) => {
57			DispatchError::UrlResolution(message)
58		}
59		reinhardt_core::exception::Error::Http(message) => DispatchError::Http(message),
60		error => DispatchError::View(error.to_string()),
61	}
62}
63
64/// Adapts a legacy [`ExceptionHandler`] to the framework-wide HTTP hook.
65///
66/// `Error::NotFound` and `Error::Http` retain their corresponding legacy
67/// categories. Error variants without a corresponding [`DispatchError`]
68/// variant are represented as legacy view errors. The original dispatch
69/// categories remain available to existing implementations, while new code
70/// should implement [`reinhardt_http::ExceptionHandler`] directly.
71///
72/// # Example
73///
74/// ```
75/// use std::sync::Arc;
76/// use async_trait::async_trait;
77/// use hyper::StatusCode;
78/// use reinhardt_core::exception::Error;
79/// use reinhardt_dispatch::{adapt_exception_handler, DispatchError, ExceptionHandler};
80/// use reinhardt_http::{Request, Response};
81///
82/// struct MyDispatchErrors;
83///
84/// #[async_trait]
85/// impl ExceptionHandler for MyDispatchErrors {
86///     async fn handle_exception(&self, _request: &Request, error: DispatchError) -> Response {
87///         let status = match error {
88///             DispatchError::UrlResolution(_) => StatusCode::NOT_FOUND,
89///             _ => StatusCode::INTERNAL_SERVER_ERROR,
90///         };
91///         Response::new(status)
92///     }
93/// }
94///
95/// #[tokio::main]
96/// async fn main() {
97///     let legacy: Arc<dyn ExceptionHandler> = Arc::new(MyDispatchErrors);
98///     let http_handler = adapt_exception_handler(legacy);
99///     let request = Request::builder().uri("/missing").build().unwrap();
100///     let response = http_handler
101///         .handle_exception(&request, Error::NotFound("route not found".into()))
102///         .await;
103///     assert_eq!(response.status, StatusCode::NOT_FOUND);
104/// }
105/// ```
106pub fn adapt_exception_handler(
107	handler: Arc<dyn ExceptionHandler>,
108) -> Arc<dyn HttpExceptionHandler> {
109	Arc::new(LegacyExceptionHandlerAdapter { handler })
110}
111
112struct LegacyExceptionHandlerAdapter {
113	handler: Arc<dyn ExceptionHandler>,
114}
115
116#[async_trait]
117impl HttpExceptionHandler for LegacyExceptionHandlerAdapter {
118	async fn handle_exception(
119		&self,
120		request: &Request,
121		error: reinhardt_core::exception::Error,
122	) -> Response {
123		self.handler
124			.handle_exception(request, exception_to_dispatch_error(error))
125			.await
126	}
127}
128
129/// Default exception handler implementation
130///
131/// Converts exceptions to appropriate HTTP error responses.
132pub struct DefaultExceptionHandler;
133
134#[async_trait]
135impl HttpExceptionHandler for DefaultExceptionHandler {
136	async fn handle_exception(
137		&self,
138		_request: &Request,
139		error: reinhardt_core::exception::Error,
140	) -> Response {
141		// Internal error details are logged server-side but never exposed
142		// in HTTP response bodies to prevent information disclosure.
143		if error.status_code() >= 500 {
144			error!("Dispatch error: {}", error);
145		} else {
146			warn!("Dispatch error: {}", error);
147		}
148		let status =
149			StatusCode::from_u16(error.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
150		let client_message = match status {
151			StatusCode::BAD_REQUEST => "Bad Request",
152			StatusCode::UNAUTHORIZED => "Unauthorized",
153			StatusCode::FORBIDDEN => "Forbidden",
154			StatusCode::NOT_FOUND => "Not Found",
155			StatusCode::METHOD_NOT_ALLOWED => "Method Not Allowed",
156			StatusCode::CONFLICT => "Conflict",
157			_ => "Internal Server Error",
158		};
159
160		build_error_response(status, client_message)
161	}
162}
163
164#[async_trait]
165impl ExceptionHandler for DefaultExceptionHandler {
166	async fn handle_exception(&self, request: &Request, error: DispatchError) -> Response {
167		HttpExceptionHandler::handle_exception(self, request, dispatch_error_to_exception(error))
168			.await
169	}
170}
171
172/// Convert an exception to an HTTP response
173///
174/// This function wraps a handler that returns `Result<Response, DispatchError>`
175/// and converts any errors into proper HTTP responses using the default exception handler.
176///
177/// The request's method, URI, version, and headers are preserved before
178/// passing ownership to the handler, so that the exception handler retains
179/// the original request context (headers, auth info) for context-aware error
180/// responses.
181pub async fn convert_exception_to_response<F, Fut>(handler: F, request: Request) -> Response
182where
183	F: FnOnce(Request) -> Fut,
184	Fut: Future<Output = Result<Response, DispatchError>>,
185{
186	// Capture the request context before consuming the request,
187	// so the exception handler has access to headers and auth info.
188	let method = request.method.clone();
189	let uri = request.uri.clone();
190	let version = request.version;
191	let headers = request.headers.clone();
192
193	match handler(request).await {
194		Ok(response) => response,
195		Err(error) => {
196			let exception_handler = DefaultExceptionHandler;
197			// Reconstruct a request with the original context for error handling
198			match Request::builder()
199				.method(method)
200				.uri(uri.to_string())
201				.version(version)
202				.headers(headers)
203				.body(Bytes::new())
204				.build()
205			{
206				Ok(context_request) => {
207					HttpExceptionHandler::handle_exception(
208						&exception_handler,
209						&context_request,
210						dispatch_error_to_exception(error),
211					)
212					.await
213				}
214				Err(_) => {
215					let mut response = Response::new(hyper::StatusCode::INTERNAL_SERVER_ERROR);
216					response.body = Bytes::from("Internal Server Error");
217					response
218				}
219			}
220		}
221	}
222}
223
224/// Trait for types that can be converted into HTTP responses
225pub trait IntoResponse {
226	/// Convert self into an HTTP response
227	fn into_response(self) -> Response;
228}
229
230impl IntoResponse for Response {
231	fn into_response(self) -> Response {
232		self
233	}
234}
235
236impl IntoResponse for String {
237	fn into_response(self) -> Response {
238		let mut response = Response::new(StatusCode::OK);
239		response.body = Bytes::from(self.into_bytes());
240		response
241	}
242}
243
244impl IntoResponse for &str {
245	fn into_response(self) -> Response {
246		let mut response = Response::new(StatusCode::OK);
247		response.body = Bytes::from(self.as_bytes().to_vec());
248		response
249	}
250}
251
252impl IntoResponse for Vec<u8> {
253	fn into_response(self) -> Response {
254		let mut response = Response::new(StatusCode::OK);
255		response.body = Bytes::from(self);
256		response
257	}
258}
259
260impl IntoResponse for StatusCode {
261	fn into_response(self) -> Response {
262		Response::new(self)
263	}
264}
265
266impl<T: IntoResponse, E: fmt::Display> IntoResponse for Result<T, E> {
267	fn into_response(self) -> Response {
268		match self {
269			Ok(value) => value.into_response(),
270			Err(error) => {
271				// Log the error details server-side only; never expose in response body
272				error!("Error converting to response: {}", error);
273				build_error_response(StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error")
274			}
275		}
276	}
277}
278
279#[cfg(test)]
280mod tests {
281	use super::*;
282	use reinhardt_http::ExceptionHandler as HttpExceptionHandler;
283	use rstest::rstest;
284	use std::sync::Arc;
285
286	fn build_request() -> Request {
287		Request::builder()
288			.method(hyper::Method::GET)
289			.uri("/")
290			.version(hyper::Version::HTTP_11)
291			.headers(hyper::HeaderMap::new())
292			.body(Bytes::new())
293			.build()
294			.unwrap()
295	}
296
297	// ==========================================================================
298	// Information Disclosure Prevention Tests (#439)
299	// ==========================================================================
300
301	#[tokio::test]
302	async fn test_internal_error_does_not_expose_details() {
303		// Arrange
304		let handler = DefaultExceptionHandler;
305		let request = build_request();
306		let error = dispatch_error_to_exception(DispatchError::Internal(
307			"database pool exhausted at /src/db/pool.rs:99".to_string(),
308		));
309
310		// Act
311		let response = HttpExceptionHandler::handle_exception(&handler, &request, error).await;
312
313		// Assert: generic message only, no internal details
314		let body = String::from_utf8(response.body.to_vec()).unwrap();
315		assert_eq!(response.status, StatusCode::INTERNAL_SERVER_ERROR);
316		assert_eq!(body, "Internal Server Error");
317		assert!(!body.contains("database"));
318		assert!(!body.contains(".rs:"));
319	}
320
321	#[tokio::test]
322	async fn test_middleware_error_does_not_expose_details() {
323		// Arrange
324		let handler = DefaultExceptionHandler;
325		let request = build_request();
326		let error = dispatch_error_to_exception(DispatchError::Middleware(
327			"JWT decode failed: invalid signature for key abc123".to_string(),
328		));
329
330		// Act
331		let response = HttpExceptionHandler::handle_exception(&handler, &request, error).await;
332
333		// Assert: generic message only, no internal details
334		let body = String::from_utf8(response.body.to_vec()).unwrap();
335		assert_eq!(response.status, StatusCode::INTERNAL_SERVER_ERROR);
336		assert_eq!(body, "Internal Server Error");
337		assert!(!body.contains("JWT"));
338		assert!(!body.contains("abc123"));
339	}
340
341	#[tokio::test]
342	async fn test_view_error_does_not_expose_details() {
343		// Arrange
344		let handler = DefaultExceptionHandler;
345		let request = build_request();
346		let error = dispatch_error_to_exception(DispatchError::View(
347			"template rendering panicked at /src/views/admin.rs:42".to_string(),
348		));
349
350		// Act
351		let response = HttpExceptionHandler::handle_exception(&handler, &request, error).await;
352
353		// Assert: generic message only, no internal details
354		let body = String::from_utf8(response.body.to_vec()).unwrap();
355		assert_eq!(response.status, StatusCode::INTERNAL_SERVER_ERROR);
356		assert_eq!(body, "Internal Server Error");
357		assert!(!body.contains("panicked"));
358		assert!(!body.contains(".rs:"));
359	}
360
361	#[tokio::test]
362	async fn test_url_resolution_returns_not_found() {
363		// Arrange
364		let handler = DefaultExceptionHandler;
365		let request = build_request();
366		let error = dispatch_error_to_exception(DispatchError::UrlResolution(
367			"no route matched".to_string(),
368		));
369
370		// Act
371		let response = HttpExceptionHandler::handle_exception(&handler, &request, error).await;
372
373		// Assert
374		let body = String::from_utf8(response.body.to_vec()).unwrap();
375		assert_eq!(response.status, StatusCode::NOT_FOUND);
376		assert_eq!(body, "Not Found");
377	}
378
379	#[tokio::test]
380	async fn test_http_error_returns_bad_request() {
381		// Arrange
382		let handler = DefaultExceptionHandler;
383		let request = build_request();
384		let error =
385			dispatch_error_to_exception(DispatchError::Http("malformed header".to_string()));
386
387		// Act
388		let response = HttpExceptionHandler::handle_exception(&handler, &request, error).await;
389
390		// Assert
391		let body = String::from_utf8(response.body.to_vec()).unwrap();
392		assert_eq!(response.status, StatusCode::BAD_REQUEST);
393		assert_eq!(body, "Bad Request");
394	}
395
396	#[rstest]
397	#[tokio::test]
398	async fn legacy_exception_handler_can_be_adapted_to_http_hook() {
399		// Arrange
400		struct LegacyTeapot;
401
402		#[async_trait]
403		impl ExceptionHandler for LegacyTeapot {
404			async fn handle_exception(&self, _request: &Request, error: DispatchError) -> Response {
405				assert!(matches!(error, DispatchError::UrlResolution(_)));
406				Response::new(StatusCode::IM_A_TEAPOT)
407			}
408		}
409
410		let request = build_request();
411		let handler = adapt_exception_handler(Arc::new(LegacyTeapot));
412
413		// Act
414		let response = HttpExceptionHandler::handle_exception(
415			handler.as_ref(),
416			&request,
417			reinhardt_core::exception::Error::NotFound("missing".to_owned()),
418		)
419		.await;
420
421		// Assert
422		assert_eq!(response.status, StatusCode::IM_A_TEAPOT);
423	}
424
425	#[rstest]
426	#[tokio::test]
427	async fn legacy_exception_handler_preserves_http_error_category() {
428		// Arrange
429		struct LegacyHttp;
430
431		#[async_trait]
432		impl ExceptionHandler for LegacyHttp {
433			async fn handle_exception(&self, _request: &Request, error: DispatchError) -> Response {
434				assert!(matches!(error, DispatchError::Http(_)));
435				Response::new(StatusCode::IM_A_TEAPOT)
436			}
437		}
438
439		let request = build_request();
440		let handler = adapt_exception_handler(Arc::new(LegacyHttp));
441
442		// Act
443		let response = HttpExceptionHandler::handle_exception(
444			handler.as_ref(),
445			&request,
446			reinhardt_core::exception::Error::Http("malformed header".to_owned()),
447		)
448		.await;
449
450		// Assert
451		assert_eq!(response.status, StatusCode::IM_A_TEAPOT);
452	}
453
454	#[test]
455	fn test_into_response_for_result_err_does_not_expose_error() {
456		// Arrange
457		let result: Result<String, String> =
458			Err("connection string: postgres://admin:pass@host/db".to_string());
459
460		// Act
461		let response = result.into_response();
462
463		// Assert
464		let body = String::from_utf8(response.body.to_vec()).unwrap();
465		assert_eq!(response.status, StatusCode::INTERNAL_SERVER_ERROR);
466		assert!(!body.contains("postgres"));
467		assert!(!body.contains("admin"));
468		assert_eq!(body, "Internal Server Error");
469	}
470}