feat: add prerendered error pages with Rust integration

- Prerender 20+ HTTP error codes (4xx/5xx) at build time
- Embed error pages in binary and serve via Axum
- Intercept error responses for HTML requests
- Add transient flag for retry-worthy errors (502/503/504)
This commit is contained in:
2026-01-06 00:43:00 -06:00
parent 48ac803bc3
commit 3c6f61c4e4
9 changed files with 255 additions and 17 deletions
+17
View File
@@ -5,6 +5,7 @@ use axum::{
use include_dir::{Dir, include_dir};
static CLIENT_ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/web/build/client");
static ERROR_PAGES: Dir = include_dir!("$CARGO_MANIFEST_DIR/web/build/prerendered/errors");
pub async fn serve_embedded_asset(uri: Uri) -> Response {
let path = uri.path();
@@ -43,3 +44,19 @@ pub async fn serve_embedded_asset(uri: Uri) -> Response {
(StatusCode::NOT_FOUND, "Asset not found").into_response()
}
}
/// Get prerendered error page HTML for a given status code.
///
/// Error pages are prerendered by SvelteKit and embedded at compile time.
/// The list of available error codes is defined in web/src/lib/error-codes.ts.
///
/// # Arguments
/// * `status_code` - HTTP status code (e.g., 404, 500)
///
/// # Returns
/// * `Some(&[u8])` - HTML content if error page exists
/// * `None` - If no prerendered page exists for this code
pub fn get_error_page(status_code: u16) -> Option<&'static [u8]> {
let filename = format!("{}.html", status_code);
ERROR_PAGES.get_file(&filename).map(|f| f.contents())
}
+73
View File
@@ -300,6 +300,57 @@ async fn api_root_404_handler(uri: axum::http::Uri) -> impl IntoResponse {
api_404_handler(uri).await
}
fn accepts_html(headers: &HeaderMap) -> bool {
if let Some(accept) = headers.get(axum::http::header::ACCEPT) {
if let Ok(accept_str) = accept.to_str() {
return accept_str.contains("text/html") || accept_str.contains("*/*");
}
}
// Default to true for requests without Accept header (browsers typically send it)
true
}
fn serve_error_page(status: StatusCode) -> Response {
let status_code = status.as_u16();
if let Some(html) = assets::get_error_page(status_code) {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("text/html; charset=utf-8"),
);
headers.insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache, no-store, must-revalidate"),
);
(status, headers, html).into_response()
} else {
// Fallback for undefined error codes (500 generic page)
tracing::warn!(
status_code,
"No prerendered error page found for status code - using fallback"
);
if let Some(fallback_html) = assets::get_error_page(500) {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("text/html; charset=utf-8"),
);
headers.insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache, no-store, must-revalidate"),
);
(status, headers, fallback_html).into_response()
} else {
// Last resort: plaintext (should never happen if 500.html exists)
(status, format!("Error {}", status_code)).into_response()
}
}
}
async fn health_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let healthy = state.health_checker.check().await;
@@ -465,6 +516,11 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
tracing::warn!(method = %method, path = %path, "Non-GET/HEAD request to non-API route");
if accepts_html(req.headers()) {
return serve_error_page(StatusCode::METHOD_NOT_ALLOWED);
}
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::ALLOW,
@@ -488,6 +544,11 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
// Block internal routes from external access
if path.starts_with("/internal/") {
tracing::warn!(path = %path, "Attempted access to internal route");
if accepts_html(req.headers()) {
return serve_error_page(StatusCode::NOT_FOUND);
}
return (StatusCode::NOT_FOUND, "Not found").into_response();
}
@@ -551,6 +612,12 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
}
}
// Intercept error responses for HTML requests
if (status.is_client_error() || status.is_server_error()) && accepts_html(req.headers())
{
return serve_error_page(status);
}
if is_head {
(status, headers).into_response()
} else {
@@ -565,6 +632,12 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
duration_ms,
"Failed to proxy to Bun"
);
// Serve 502 error page instead of plaintext
if accepts_html(req.headers()) {
return serve_error_page(StatusCode::BAD_GATEWAY);
}
(
StatusCode::BAD_GATEWAY,
format!("Failed to render page: {err}"),