From 1b7c0b294c9cd0de99f82ea119523ae51cfcd9eb Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Fri, 8 May 2026 10:17:29 +1000 Subject: [PATCH 1/7] feat(http/unstable): `HttpError` --- http/deno.json | 3 +- http/unstable_error.ts | 162 ++++++++++++++++++++++++++++++++++++ http/unstable_error_test.ts | 56 +++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 http/unstable_error.ts create mode 100644 http/unstable_error_test.ts diff --git a/http/deno.json b/http/deno.json index a9a3b73494de..c0bc1492fc75 100644 --- a/http/deno.json +++ b/http/deno.json @@ -21,6 +21,7 @@ "./user-agent": "./user_agent.ts", "./unstable-route": "./unstable_route.ts", "./unstable-cache-control": "./unstable_cache_control.ts", - "./unstable-message-signatures": "./unstable_message_signatures.ts" + "./unstable-message-signatures": "./unstable_message_signatures.ts", + "./unstable-error": "./unstable_error.ts" } } diff --git a/http/unstable_error.ts b/http/unstable_error.ts new file mode 100644 index 000000000000..ca175d5a2ff1 --- /dev/null +++ b/http/unstable_error.ts @@ -0,0 +1,162 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. +import { type ErrorStatus, STATUS_TEXT } from "./status.ts"; + +/** + * Options for {@linkcode HttpError}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export interface HttpErrorOptions extends ErrorOptions { + /** + * Configuration options for the HTTP response associated with this error. + */ + init?: ResponseInit; +} + +/** + * An error class for representing HTTP errors with status codes. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * Extends the standard {@linkcode Error} class to include HTTP-specific + * properties such as status codes and optional response initialization options. + * It's commonly used in route handlers to signal HTTP errors that should be + * returned to the client. + * + * @param message Optional error message. Defaults to the standard status text for the given status code + * @param options Optional error options including cause and response init configuration + * + * @example Usage without custom message or options + * ```ts + * import { HttpError } from "@std/http/unstable-error"; + * import { assertEquals, assertInstanceOf } from "@std/assert"; + * + * try { + * throw new HttpError(404); + * } catch (error) { + * assertInstanceOf(error, HttpError); + * assertEquals(error.status, 404); + * assertEquals(error.message, "Not Found"); + * } + * ``` + * + * @example Usage with custom message + * ```ts + * import { HttpError } from "@std/http/unstable-error"; + * import { assertEquals, assertInstanceOf } from "@std/assert"; + * + * try { + * throw new HttpError(500, "Something went wrong"); + * } catch (error) { + * assertInstanceOf(error, HttpError); + * assertEquals(error.status, 500); + * assertEquals(error.message, "Something went wrong"); + * } + * ``` + * + * @example Usage with response init options + * ```ts + * import { HttpError } from "@std/http/unstable-error"; + * import { assertEquals, assertInstanceOf } from "@std/assert"; + * + * try { + * throw new HttpError(403, "Forbidden", { + * init: { headers: { "WWW-Authenticate": 'Basic realm="Secure Area"' } }, + * }); + * } catch (error) { + * assertInstanceOf(error, HttpError); + * assertEquals(error.status, 403); + * assertEquals(error.message, "Forbidden"); + * assertEquals( + * (error.init.headers as Record)["WWW-Authenticate"], + * 'Basic realm="Secure Area"', + * ); + * } + * ``` + * + * @example Usage with cause + * ```ts + * import { HttpError } from "@std/http/unstable-error"; + * import { assertEquals, assertInstanceOf } from "@std/assert"; + * + * try { + * throw new HttpError(500, "Internal Server Error", { + * cause: new Error("Database connection failed"), + * }); + * } catch (error) { + * assertInstanceOf(error, HttpError); + * assertEquals(error.status, 500); + * assertEquals(error.message, "Internal Server Error"); + * assertInstanceOf(error.cause, Error); + * assertEquals(error.cause?.message, "Database connection failed"); + * } + * ``` + */ +export class HttpError extends Error { + /** + * The HTTP status code (e.g., 404, 500, 403) + * + * @example Usage + * ```ts + * import { HttpError } from "@std/http/unstable-error"; + * import { assertEquals, assertInstanceOf } from "@std/assert"; + * + * try { + * throw new HttpError(404); + * } catch (error) { + * assertInstanceOf(error, HttpError); + * assertEquals(error.status, 404); + * assertEquals(error.message, "Not Found"); + * } + * ``` + */ + status: ErrorStatus; + /** + * Configuration options for the HTTP response associated with this error. + * + * @example Usage + * ```ts + * import { HttpError } from "@std/http/unstable-error"; + * import { assertEquals, assertInstanceOf } from "@std/assert"; + * + * try { + * throw new HttpError(403, "Forbidden", { + * init: { headers: { "WWW-Authenticate": 'Basic realm="Secure Area"' } }, + * }); + * } catch (error) { + * assertInstanceOf(error, HttpError); + * assertEquals(error.status, 403); + * assertEquals(error.message, "Forbidden"); + * assertEquals( + * (error.init.headers as Record)["WWW-Authenticate"], + * 'Basic realm="Secure Area"', + * ); + * } + * ``` + */ + init: ResponseInit; + + /** + * Constructs a new instance. + * + * Extends the standard {@linkcode Error} class to include HTTP-specific + * properties such as status codes and optional response initialization options. + * It's commonly used in route handlers to signal HTTP errors that should be + * returned to the client. + * + * @param status The HTTP status code (e.g., 404, 500, 403) + * @param message Optional error message. Defaults to the standard status text for the given status code + * @param options Optional error options including cause and response init configuration + */ + constructor( + status: ErrorStatus, + message: string = STATUS_TEXT[status], + options?: HttpErrorOptions, + ) { + super(message, options); + this.name = this.constructor.name; + this.status = status; + this.init = { ...options?.init, status, statusText: STATUS_TEXT[status] }; + } +} diff --git a/http/unstable_error_test.ts b/http/unstable_error_test.ts new file mode 100644 index 000000000000..4c7cd6c99087 --- /dev/null +++ b/http/unstable_error_test.ts @@ -0,0 +1,56 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +import { assertEquals, assertInstanceOf } from "@std/assert"; +import { HttpError } from "./unstable_error.ts"; + +Deno.test("HttpError", async (t) => { + await t.step("initialises with correct defaults", () => { + const error = new HttpError(500); + assertInstanceOf(error, Error); + assertEquals(error.name, "HttpError"); + assertEquals(error.status, 500); + assertEquals(error.message, "Internal Server Error"); + assertEquals(error.cause, undefined); + assertEquals(error.init, { + status: 500, + statusText: "Internal Server Error", + }); + }); + + await t.step("initialises with custom properties", () => { + const error = new HttpError(401, "Unauthorized", { + cause: new Error("Underlying error"), + init: { headers: { "WWW-Authenticate": 'Basic realm="Secure Area"' } }, + }); + assertInstanceOf(error, Error); + assertEquals(error.name, "HttpError"); + assertEquals(error.status, 401); + assertEquals(error.message, "Unauthorized"); + assertInstanceOf(error.cause, Error); + assertEquals(error.cause?.message, "Underlying error"); + assertEquals( + (error.init.headers as Record)["WWW-Authenticate"], + 'Basic realm="Secure Area"', + ); + }); + + await t.step("defaults message to status text when omitted", () => { + assertEquals(new HttpError(404).message, "Not Found"); + assertEquals(new HttpError(400).message, "Bad Request"); + assertEquals(new HttpError(503).message, "Service Unavailable"); + }); + + await t.step( + "merges init headers alongside default status and statusText", + () => { + const error = new HttpError(403, "Forbidden", { + init: { headers: { "X-Custom": "value" } }, + }); + assertEquals(error.init.status, 403); + assertEquals(error.init.statusText, "Forbidden"); + assertEquals( + (error.init.headers as Record)["X-Custom"], + "value", + ); + }, + ); +}); From 4ba2ca208b8f54d405c8c344ae75aa0d711fc796 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Fri, 8 May 2026 10:38:07 +1000 Subject: [PATCH 2/7] Tweaks --- http/unstable_error.ts | 5 +++++ http/unstable_error_test.ts | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/http/unstable_error.ts b/http/unstable_error.ts index ca175d5a2ff1..dcda657abc45 100644 --- a/http/unstable_error.ts +++ b/http/unstable_error.ts @@ -24,6 +24,7 @@ export interface HttpErrorOptions extends ErrorOptions { * It's commonly used in route handlers to signal HTTP errors that should be * returned to the client. * + * @param status The HTTP status code (e.g., 404, 500, 403) * @param message Optional error message. Defaults to the standard status text for the given status code * @param options Optional error options including cause and response init configuration * @@ -115,6 +116,10 @@ export class HttpError extends Error { /** * Configuration options for the HTTP response associated with this error. * + * `init.status` and `init.statusText` always reflect the standard HTTP + * values for the given status code. A custom `message` passed to the + * constructor does not affect `init.statusText`. + * * @example Usage * ```ts * import { HttpError } from "@std/http/unstable-error"; diff --git a/http/unstable_error_test.ts b/http/unstable_error_test.ts index 4c7cd6c99087..08a1d1d20b2d 100644 --- a/http/unstable_error_test.ts +++ b/http/unstable_error_test.ts @@ -53,4 +53,13 @@ Deno.test("HttpError", async (t) => { ); }, ); + + await t.step( + "init.statusText reflects standard HTTP text, not custom message", + () => { + const error = new HttpError(403, "Access denied for this resource"); + assertEquals(error.message, "Access denied for this resource"); + assertEquals(error.init.statusText, "Forbidden"); + }, + ); }); From 5ce5c24e45957adcc660d6de976b97ca89a27a88 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Fri, 8 May 2026 10:47:44 +1000 Subject: [PATCH 3/7] Cleanup --- http/unstable_error_test.ts | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/http/unstable_error_test.ts b/http/unstable_error_test.ts index 08a1d1d20b2d..0af3718715bf 100644 --- a/http/unstable_error_test.ts +++ b/http/unstable_error_test.ts @@ -32,34 +32,4 @@ Deno.test("HttpError", async (t) => { 'Basic realm="Secure Area"', ); }); - - await t.step("defaults message to status text when omitted", () => { - assertEquals(new HttpError(404).message, "Not Found"); - assertEquals(new HttpError(400).message, "Bad Request"); - assertEquals(new HttpError(503).message, "Service Unavailable"); - }); - - await t.step( - "merges init headers alongside default status and statusText", - () => { - const error = new HttpError(403, "Forbidden", { - init: { headers: { "X-Custom": "value" } }, - }); - assertEquals(error.init.status, 403); - assertEquals(error.init.statusText, "Forbidden"); - assertEquals( - (error.init.headers as Record)["X-Custom"], - "value", - ); - }, - ); - - await t.step( - "init.statusText reflects standard HTTP text, not custom message", - () => { - const error = new HttpError(403, "Access denied for this resource"); - assertEquals(error.message, "Access denied for this resource"); - assertEquals(error.init.statusText, "Forbidden"); - }, - ); }); From 1686218776001d996604a93814f14b9c55432b41 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Tue, 12 May 2026 14:51:21 +1000 Subject: [PATCH 4/7] Remove `statusText` handling --- http/unstable_error.ts | 2 +- http/unstable_error_test.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/http/unstable_error.ts b/http/unstable_error.ts index dcda657abc45..b4e5f12cc514 100644 --- a/http/unstable_error.ts +++ b/http/unstable_error.ts @@ -162,6 +162,6 @@ export class HttpError extends Error { super(message, options); this.name = this.constructor.name; this.status = status; - this.init = { ...options?.init, status, statusText: STATUS_TEXT[status] }; + this.init = { status, ...options?.init }; } } diff --git a/http/unstable_error_test.ts b/http/unstable_error_test.ts index 0af3718715bf..d86fa3f7dc0c 100644 --- a/http/unstable_error_test.ts +++ b/http/unstable_error_test.ts @@ -12,7 +12,6 @@ Deno.test("HttpError", async (t) => { assertEquals(error.cause, undefined); assertEquals(error.init, { status: 500, - statusText: "Internal Server Error", }); }); From b3c043ebcdd33d5426ec025507e9731fc833306e Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Fri, 15 May 2026 08:06:39 +1000 Subject: [PATCH 5/7] Apply suggestions --- http/unstable_error.ts | 10 ++------ http/unstable_error_test.ts | 50 ++++++++++++++++++------------------- 2 files changed, 26 insertions(+), 34 deletions(-) diff --git a/http/unstable_error.ts b/http/unstable_error.ts index b4e5f12cc514..fb4f96775ba8 100644 --- a/http/unstable_error.ts +++ b/http/unstable_error.ts @@ -116,9 +116,8 @@ export class HttpError extends Error { /** * Configuration options for the HTTP response associated with this error. * - * `init.status` and `init.statusText` always reflect the standard HTTP - * values for the given status code. A custom `message` passed to the - * constructor does not affect `init.statusText`. + * `init.status` always reflects the standard HTTP value for the given status + * code. * * @example Usage * ```ts @@ -145,11 +144,6 @@ export class HttpError extends Error { /** * Constructs a new instance. * - * Extends the standard {@linkcode Error} class to include HTTP-specific - * properties such as status codes and optional response initialization options. - * It's commonly used in route handlers to signal HTTP errors that should be - * returned to the client. - * * @param status The HTTP status code (e.g., 404, 500, 403) * @param message Optional error message. Defaults to the standard status text for the given status code * @param options Optional error options including cause and response init configuration diff --git a/http/unstable_error_test.ts b/http/unstable_error_test.ts index d86fa3f7dc0c..6a0510e42c6f 100644 --- a/http/unstable_error_test.ts +++ b/http/unstable_error_test.ts @@ -2,33 +2,31 @@ import { assertEquals, assertInstanceOf } from "@std/assert"; import { HttpError } from "./unstable_error.ts"; -Deno.test("HttpError", async (t) => { - await t.step("initialises with correct defaults", () => { - const error = new HttpError(500); - assertInstanceOf(error, Error); - assertEquals(error.name, "HttpError"); - assertEquals(error.status, 500); - assertEquals(error.message, "Internal Server Error"); - assertEquals(error.cause, undefined); - assertEquals(error.init, { - status: 500, - }); +Deno.test("HttpError initialises with correct defaults", () => { + const error = new HttpError(500); + assertInstanceOf(error, Error); + assertEquals(error.name, "HttpError"); + assertEquals(error.status, 500); + assertEquals(error.message, "Internal Server Error"); + assertEquals(error.cause, undefined); + assertEquals(error.init, { + status: 500, }); +}); - await t.step("initialises with custom properties", () => { - const error = new HttpError(401, "Unauthorized", { - cause: new Error("Underlying error"), - init: { headers: { "WWW-Authenticate": 'Basic realm="Secure Area"' } }, - }); - assertInstanceOf(error, Error); - assertEquals(error.name, "HttpError"); - assertEquals(error.status, 401); - assertEquals(error.message, "Unauthorized"); - assertInstanceOf(error.cause, Error); - assertEquals(error.cause?.message, "Underlying error"); - assertEquals( - (error.init.headers as Record)["WWW-Authenticate"], - 'Basic realm="Secure Area"', - ); +Deno.test("HttpError initialises with custom properties", () => { + const error = new HttpError(401, "Unauthorized", { + cause: new Error("Underlying error"), + init: { headers: { "WWW-Authenticate": 'Basic realm="Secure Area"' } }, }); + assertInstanceOf(error, Error); + assertEquals(error.name, "HttpError"); + assertEquals(error.status, 401); + assertEquals(error.message, "Unauthorized"); + assertInstanceOf(error.cause, Error); + assertEquals(error.cause?.message, "Underlying error"); + assertEquals( + (error.init.headers as Record)["WWW-Authenticate"], + 'Basic realm="Secure Area"', + ); }); From efef3c2cb082fcdf95ec4e976ac3f9ea5b7b5481 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Wed, 27 May 2026 07:22:14 +1000 Subject: [PATCH 6/7] Suggestions --- http/unstable_error.ts | 7 ++++--- http/unstable_error_test.ts | 6 +++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/http/unstable_error.ts b/http/unstable_error.ts index fb4f96775ba8..db1d8ba8b515 100644 --- a/http/unstable_error.ts +++ b/http/unstable_error.ts @@ -116,8 +116,9 @@ export class HttpError extends Error { /** * Configuration options for the HTTP response associated with this error. * - * `init.status` always reflects the standard HTTP value for the given status - * code. + * `init.status` always equals the status argument passed to the constructor + * and represents the HTTP status code. Other {@linkcode ResponseInit} fields + * (`headers`, `statusText`) come from `options.init` if supplied. * * @example Usage * ```ts @@ -156,6 +157,6 @@ export class HttpError extends Error { super(message, options); this.name = this.constructor.name; this.status = status; - this.init = { status, ...options?.init }; + this.init = { ...options?.init, status }; } } diff --git a/http/unstable_error_test.ts b/http/unstable_error_test.ts index 6a0510e42c6f..bbc91c8c4561 100644 --- a/http/unstable_error_test.ts +++ b/http/unstable_error_test.ts @@ -17,7 +17,10 @@ Deno.test("HttpError initialises with correct defaults", () => { Deno.test("HttpError initialises with custom properties", () => { const error = new HttpError(401, "Unauthorized", { cause: new Error("Underlying error"), - init: { headers: { "WWW-Authenticate": 'Basic realm="Secure Area"' } }, + init: { + headers: { "WWW-Authenticate": 'Basic realm="Secure Area"' }, + status: 400, + }, }); assertInstanceOf(error, Error); assertEquals(error.name, "HttpError"); @@ -29,4 +32,5 @@ Deno.test("HttpError initialises with custom properties", () => { (error.init.headers as Record)["WWW-Authenticate"], 'Basic realm="Secure Area"', ); + assertEquals(error.init.status, error.status); }); From 393fabaee36b8609df843d7860fee94534689487 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Wed, 27 May 2026 07:24:52 +1000 Subject: [PATCH 7/7] Tweaks --- http/unstable_error_test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/http/unstable_error_test.ts b/http/unstable_error_test.ts index bbc91c8c4561..a71429ae1283 100644 --- a/http/unstable_error_test.ts +++ b/http/unstable_error_test.ts @@ -2,7 +2,7 @@ import { assertEquals, assertInstanceOf } from "@std/assert"; import { HttpError } from "./unstable_error.ts"; -Deno.test("HttpError initialises with correct defaults", () => { +Deno.test("new HttpError() defaults message to STATUS_TEXT[status]", () => { const error = new HttpError(500); assertInstanceOf(error, Error); assertEquals(error.name, "HttpError"); @@ -14,7 +14,7 @@ Deno.test("HttpError initialises with correct defaults", () => { }); }); -Deno.test("HttpError initialises with custom properties", () => { +Deno.test("new HttpError() forwards properties from options", () => { const error = new HttpError(401, "Unauthorized", { cause: new Error("Underlying error"), init: {