From 17ef5f50b324a17c8b4625e0b576fca2c62c3488 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 15:03:17 +0000 Subject: [PATCH] dream: error-handling middleware (dream-catch) + status reason phrases + 15 tests Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/dream/conformance.sh | 2 + lib/dream/error.sx | 41 ++++++++++++++++++ lib/dream/tests/error.sx | 90 ++++++++++++++++++++++++++++++++++++++++ plans/dream-on-sx.md | 11 ++++- 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 lib/dream/error.sx create mode 100644 lib/dream/tests/error.sx diff --git a/lib/dream/conformance.sh b/lib/dream/conformance.sh index 7596f771..fafbf810 100644 --- a/lib/dream/conformance.sh +++ b/lib/dream/conformance.sh @@ -30,6 +30,7 @@ MODULES=( "lib/dream/form.sx" "lib/dream/websocket.sx" "lib/dream/static.sx" + "lib/dream/error.sx" "lib/dream/run.sx" "lib/dream/demos/hello.sx" "lib/dream/demos/counter.sx" @@ -47,6 +48,7 @@ SUITES=( "form dream-fo-tests-run! lib/dream/tests/form.sx" "websocket dream-ws-tests-run! lib/dream/tests/websocket.sx" "static dream-st-tests-run! lib/dream/tests/static.sx" + "error dream-er-tests-run! lib/dream/tests/error.sx" "run dream-rn-tests-run! lib/dream/tests/run.sx" "demos dream-dm-tests-run! lib/dream/tests/demos.sx" ) diff --git a/lib/dream/error.sx b/lib/dream/error.sx new file mode 100644 index 00000000..9f1d3174 --- /dev/null +++ b/lib/dream/error.sx @@ -0,0 +1,41 @@ +;; lib/dream/error.sx — Dream-on-SX status phrases + error-handling middleware. +;; dream-catch wraps a handler and turns a raised error into a 500 response (or a +;; custom page). Depends on types.sx. + +;; ── status reason phrases ────────────────────────────────────────── +(define dr/status-texts {:206 "Partial Content" :202 "Accepted" :422 "Unprocessable Entity" :400 "Bad Request" :302 "Found" :204 "No Content" :502 "Bad Gateway" :429 "Too Many Requests" :301 "Moved Permanently" :415 "Unsupported Media Type" :405 "Method Not Allowed" :303 "See Other" :401 "Unauthorized" :304 "Not Modified" :503 "Service Unavailable" :404 "Not Found" :308 "Permanent Redirect" :504 "Gateway Timeout" :416 "Range Not Satisfiable" :500 "Internal Server Error" :307 "Temporary Redirect" :201 "Created" :501 "Not Implemented" :409 "Conflict" :200 "OK" :410 "Gone" :403 "Forbidden"}) + +(define + dream-status-text + (fn (status) (or (get dr/status-texts (str status)) "Unknown"))) +(define + dream-status-line + (fn (status) (str status " " (dream-status-text status)))) + +;; ── error-handling middleware ────────────────────────────────────── +(define + dream-default-error-page + (fn + (req e) + (dream-html-status + 500 + (str "

" (dream-status-line 500) "

")))) + +(define + dream-catch-with + (fn + (on-error) + (fn + (next) + (fn (req) (guard (e (true (on-error req e))) (next req)))))) + +(define dream-catch (dream-catch-with dream-default-error-page)) + +;; a fallback handler that renders a status page for any code +(define + dream-status-page + (fn + (status) + (dream-html-status + status + (str "

" (dream-status-line status) "

")))) diff --git a/lib/dream/tests/error.sx b/lib/dream/tests/error.sx new file mode 100644 index 00000000..27ad1e7c --- /dev/null +++ b/lib/dream/tests/error.sx @@ -0,0 +1,90 @@ +;; lib/dream/tests/error.sx — status phrases + dream-catch. + +(define dream-er-pass 0) +(define dream-er-fail 0) +(define dream-er-fails (list)) + +(define + dream-er-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-er-pass (+ dream-er-pass 1)) + (begin + (set! dream-er-fail (+ dream-er-fail 1)) + (append! dream-er-fails {:name name :actual actual :expected expected}))))) + +;; ── status phrases ───────────────────────────────────────────────── +(dream-er-test "200 OK" (dream-status-text 200) "OK") +(dream-er-test "404 Not Found" (dream-status-text 404) "Not Found") +(dream-er-test + "405 phrase" + (dream-status-text 405) + "Method Not Allowed") +(dream-er-test + "500 phrase" + (dream-status-text 500) + "Internal Server Error") +(dream-er-test "unknown phrase" (dream-status-text 599) "Unknown") +(dream-er-test "status line" (dream-status-line 404) "404 Not Found") +(dream-er-test + "status page status" + (dream-status (dream-status-page 403)) + 403) +(dream-er-test + "status page body" + (dream-resp-body (dream-status-page 403)) + "

403 Forbidden

") + +;; ── dream-catch ──────────────────────────────────────────────────── +(define dream-er-boom (fn (req) (error "kaboom"))) +(define dream-er-ok (fn (req) (dream-text "fine"))) + +(dream-er-test + "catch normal passes through" + (dream-resp-body + ((dream-catch dream-er-ok) (dream-request "GET" "/" {} ""))) + "fine") +(dream-er-test + "catch error -> 500" + (dream-status + ((dream-catch dream-er-boom) (dream-request "GET" "/" {} ""))) + 500) +(dream-er-test + "catch 500 body" + (dream-resp-body + ((dream-catch dream-er-boom) (dream-request "GET" "/" {} ""))) + "

500 Internal Server Error

") + +;; custom error page receives the error +(define + dream-er-custom + (dream-catch-with (fn (req e) (dream-text (str "ERR:" e))))) +(dream-er-test + "custom error page" + (dream-resp-body + ((dream-er-custom dream-er-boom) (dream-request "GET" "/" {} ""))) + "ERR:kaboom") +(dream-er-test + "custom passes normal through" + (dream-resp-body + ((dream-er-custom dream-er-ok) (dream-request "GET" "/" {} ""))) + "fine") + +;; catch composes around a router +(define + dream-er-app + (dream-catch + (dream-router + (list (dream-get "/boom" dream-er-boom) (dream-get "/ok" dream-er-ok))))) +(dream-er-test + "router error caught" + (dream-status (dream-er-app (dream-request "GET" "/boom" {} ""))) + 500) +(dream-er-test + "router ok intact" + (dream-resp-body (dream-er-app (dream-request "GET" "/ok" {} ""))) + "fine") + +(define dream-er-tests-run! (fn () {:total (+ dream-er-pass dream-er-fail) :passed dream-er-pass :failed dream-er-fail :fails dream-er-fails})) diff --git a/plans/dream-on-sx.md b/plans/dream-on-sx.md index 88e7e476..99ec7e39 100644 --- a/plans/dream-on-sx.md +++ b/plans/dream-on-sx.md @@ -92,6 +92,13 @@ with extensions + hardening below. in this worktree every `sx-tree` *edit* tool (`sx_replace_node`, `sx_replace_by_pattern`, `sx_insert_near`) raises a yojson `Expected string, got null` error — only `sx_write_file` works, so edits rewrite the whole file. +- **2026-06-07 — Ext: error handling + status phrases** (`lib/dream/error.sx`, 15 + tests, 282 total). `dream-status-text` / `dream-status-line` reason-phrase map (string + keys); `dream-status-page` renders a status page. `dream-catch` is a `guard`-based + middleware that turns a raised error into a 500 (`dream-catch-with on-error` for a + custom page receiving `(req e)`); normal responses pass through untouched, composes + around a router. (`guard` catches explicit `(error …)` raises; `e` stringifies to the + message.) ## Extensions (post-roadmap) @@ -99,9 +106,9 @@ The five-types core is complete; these harden it toward a production HTTP front - [x] **Router HTTP correctness**: 405 Method Not Allowed + `Allow` header; automatic HEAD (serve the GET handler with an empty body). -- [ ] **Status reason phrases** + `dream-status-text`. +- [x] **Status reason phrases** + `dream-status-text` (`lib/dream/error.sx`). - [ ] **CORS middleware** (`dream-cors`). -- [ ] **Error-handling middleware** (`dream-catch` / custom 404 + 500 templates). +- [x] **Error-handling middleware** (`dream-catch` / custom 500 templates; `guard`-based). - [ ] **Signed session cookies** (the noted hardening — sign the sid). - [ ] **JSON helpers** (build from dict; parse to dict). - [ ] **Query/header convenience** (`dream-queries`, defaults).