Skip to main content

max / makenotwork

Give the app's own 404 a body custom_domain_fallback returned a bare StatusCode::NOT_FOUND for MNW hosts on the assumption that Caddy's handle_errors would brand it. It does not: handle_errors fires only on errors Caddy itself generates, never on a 4xx a healthy upstream returned, so every unknown path on makenot.work answered with an empty body. Verified against prod, where three handle_errors variants (bare proxy, handle_response, disk read) all returned zero bytes. AppError::NotFound renders the branded template, and json_error_layer still swaps it for JSON on /api routes. The existing test only checked the status, so it passed throughout; it now asserts the body too.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 01:11 UTC
Signed with PGP, not checked
Commit: 7e0915bd13a57765d0ca75964ee85b5fe64f126b
Parent: ded3077
2 files changed, +26 insertions, -9 deletions
@@ -6,7 +6,7 @@
6 6
7 7 use axum::{
8 8 extract::State,
9 - http::{HeaderMap, StatusCode, Uri},
9 + http::{HeaderMap, Uri},
10 10 response::{IntoResponse, Response},
11 11 };
12 12 use sqlx::PgPool;
@@ -95,7 +95,7 @@
95 95 )
96 96 .await
97 97 }
98 - _ => return Some(StatusCode::NOT_FOUND.into_response()),
98 + _ => return Some(AppError::NotFound.into_response()),
99 99 };
100 100
101 101 Some(match result {
@@ -104,7 +104,7 @@
104 104 // "not found" (that made custom-domain outages look like missing pages
105 105 // with no signal, audit Run 13 Obs). Log it and let the AppError
106 106 // responder pick the real status (500 for infra errors).
107 - Err(AppError::NotFound) => StatusCode::NOT_FOUND.into_response(),
107 + Err(AppError::NotFound) => AppError::NotFound.into_response(),
108 108 Err(e) => {
109 109 tracing::error!(error = ?e, host = %host, "custom domain render failed");
110 110 e.into_response()
@@ -133,18 +133,23 @@
133 133 MaybeUserVerified(maybe_user): MaybeUserVerified,
134 134 ) -> Response {
135 135 let Some(host) = extract_host(&headers) else {
136 - return StatusCode::NOT_FOUND.into_response();
136 + return AppError::NotFound.into_response();
137 137 };
138 138
139 - // MNW domains → standard 404
139 + // MNW domains fall through to the app's own branded 404. It must carry a
140 + // body: Caddy's `handle_errors` fires only on errors Caddy itself generates,
141 + // never on a 4xx that a healthy upstream returned, so a bare
142 + // `StatusCode::NOT_FOUND` here reached the visitor as an empty page.
143 + // `AppError::NotFound` renders the branded template, and `json_error_layer`
144 + // still swaps it for JSON on /api routes.
140 145 if is_mnw_domain(&host) {
141 - return StatusCode::NOT_FOUND.into_response();
146 + return AppError::NotFound.into_response();
142 147 }
143 148
144 149 // Look up custom domain
145 150 let user_id = match caches.domain_cache.get(&host) {
146 151 Some(entry) => *entry.value(),
147 - None => return StatusCode::NOT_FOUND.into_response(),
152 + None => return AppError::NotFound.into_response(),
148 153 };
149 154
150 155 // Route by path segments
@@ -177,14 +182,14 @@
177 182 )
178 183 .await
179 184 }
180 - _ => return StatusCode::NOT_FOUND.into_response(),
185 + _ => return AppError::NotFound.into_response(),
181 186 };
182 187
183 188 match result {
184 189 Ok(response) => response,
185 190 // Genuine miss → clean 404; a DB/render error must surface (log + real
186 191 // status) rather than silently reading as "not found" (audit Run 13 Obs).
187 - Err(AppError::NotFound) => StatusCode::NOT_FOUND.into_response(),
192 + Err(AppError::NotFound) => AppError::NotFound.into_response(),
188 193 Err(e) => {
189 194 tracing::error!(error = ?e, host = %host, "custom domain render failed");
190 195 e.into_response()
@@ -282,6 +282,18 @@
282 282 "MNW domain unmatched path should 404, got {}",
283 283 resp.status
284 284 );
285 +
286 + // And it must carry the branded page, not a bare status. Caddy's
287 + // handle_errors only fires on errors Caddy itself generates, never on a 4xx
288 + // returned by a healthy upstream, so nothing downstream will supply a body
289 + // the app leaves empty: an empty 404 here reaches the visitor as a blank
290 + // page.
291 + assert!(
292 + resp.text.contains("404 · Not Found") && resp.text.contains("error-page-message"),
293 + "404 should render the branded error page, got {} bytes: {}",
294 + resp.text.len(),
295 + resp.text
296 + );
285 297 }
286 298
287 299 #[tokio::test]