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).