Skip to content

feat(http/unstable): HttpError#7132

Open
iuioiua wants to merge 8 commits into
denoland:mainfrom
iuioiua:http-error
Open

feat(http/unstable): HttpError#7132
iuioiua wants to merge 8 commits into
denoland:mainfrom
iuioiua:http-error

Conversation

@iuioiua
Copy link
Copy Markdown
Contributor

@iuioiua iuioiua commented May 8, 2026

This PR brings back the HttpError class, which I regret removing in #3736 (sorry Kitson 🥲). It's a very versatile class that can be called anywhere in the call stack and handled in a unified way in the "master" request handler. I've been using this in my own projects and love it.

This class is already implemented in Oak (similarly) and Fresh (a more minimal version).

I think it'd also be worth updating handlers within @std/http to throw errors instead of returning responses, allowing the dev to handle their own errors.

Written by me. Checked by Claude Code.

@github-actions github-actions Bot added the http label May 8, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 8, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.57%. Comparing base (f0c9f14) to head (c656401).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #7132   +/-   ##
=======================================
  Coverage   94.57%   94.57%           
=======================================
  Files         636      637    +1     
  Lines       52138    52153   +15     
  Branches     9399     9400    +1     
=======================================
+ Hits        49311    49326   +15     
  Misses       2249     2249           
  Partials      578      578           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@iuioiua iuioiua marked this pull request as ready for review May 8, 2026 00:58
@lionel-rowe
Copy link
Copy Markdown
Contributor

lionel-rowe commented May 11, 2026

What should happen here?

import { HttpError } from '@std/http/unstable-error'

const { addr: { hostname, port } } = Deno.serve(() => new Response(null, {
    status: 555,
}))

const res = await fetch(`http://${hostname}:${port}/`)
console.error(new HttpError(res.status))

Current behaviour:

  • Type checking fails upon construction ("Argument of type 'number' is not assignable to parameter of type 'ErrorStatus'.")
  • No runtime error
  • init.statusText is undefined

Also:

  • If res.status is something more normal like 404, type checking still fails as TypeScript can't infer it, but init.statusText is populated as expected.
  • If the status is a non-error status like 200 (whether statically analyzable or not), TypeScript rejects it but init.statusText is still populated.

IMO current behaviour is fine as you can just cast new HttpError(res.status as 400) or something (better yet: res.status as ErrorStatus, although that requires an additional import), it's just a little unexpected that the cast is necessary given that runtime behaviour isn't affected by the cast being incorrect.

@iuioiua
Copy link
Copy Markdown
Contributor Author

iuioiua commented May 12, 2026

On second thought, setting the statusText doesn't really provide any value. So I just removed it. Also, the error is intended to be thrown then caught within the request handler. And yes, if passing a number to the constructor, as ErrorStatus is the way to go.

Copy link
Copy Markdown

@fibibot fibibot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. JSDoc on the init property (lines 119-121) claims init.status and init.statusText always reflect the standard HTTP values for the given status code. Neither is true after the recent commit: init = { status, ...options?.init } never assigns statusText, and options.init.status wins over the constructor's status (spread comes last). Either re-add the auto-population, or rewrite the paragraph to describe the actual contract (init.status defaults to status and is overridable; init.statusText is whatever the user passes).
  • nit: while you're in there, the constructor doc block (lines 145-156) duplicates the class-level description verbatim. Constructor JSDoc can be a one-liner or just dropped — TypeDoc/LSP already shows the class doc on new HttpError(...).
  • nit: subtests are named "initialises with correct defaults" / "initialises with custom properties". The repo convention is symbolName() does X when Y — e.g. "new HttpError() defaults message to STATUS_TEXT[status]".

@iuioiua iuioiua requested a review from fibibot May 14, 2026 22:52
Copy link
Copy Markdown
Member

@bartlomieju bartlomieju left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick re-review after the "Apply suggestions" commit (b3c043e). Three of fibibot's earlier points are only partially resolved — flagging them again with concrete fixes.

There's also a more substantive issue I want to surface that the previous round didn't catch: this.init = { status, ...options?.init } lets a user-supplied options.init.status silently override the constructor's status argument, leaving error.status !== error.init.status. Either the spread order is wrong (should be { ...options?.init, status }) or the type should disallow status inside options.init. Without a test pinning down the intent, this will go in as undefined behavior.

Apart from that the module is in good shape — small focused API, decent doc examples, browser-compatible, sits cleanly in the unstable-* namespace. Once the items below are addressed I'm happy to approve.

Comment thread http/unstable_error.ts Outdated
Comment on lines +119 to +120
* `init.status` always reflects the standard HTTP value for the given status
* code.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This claim is still misleading after the statusText removal:

  1. init.status is a number, so "reflects the standard HTTP value for the given status code" doesn't quite parse — that phrasing made sense for statusText (where there's a canonical string like "Not Found"), but for .status it's just "the same number".
  2. More importantly, it's not always true: because this.init = { status, ...options?.init }, a caller passing { init: { status: 999 } } will see error.init.status === 999. See my comment on line 159 — until that's resolved, this sentence describes a contract that isn't enforced.

Suggested rewrite once the runtime contract is fixed:

init.status always equals the status argument passed to the constructor. Other ResponseInit fields (headers, statusText) come from options.init if supplied.

Comment thread http/unstable_error.ts Outdated
super(message, options);
this.name = this.constructor.name;
this.status = status;
this.init = { status, ...options?.init };
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spread order looks reversed. With { status, ...options?.init }, a caller can do:

const err = new HttpError(500, undefined, { init: { status: 200 } });
// err.status         === 500
// err.init.status    === 200  // 😬

That leaves the instance in an inconsistent state, and the JSDoc on line 119 documents the opposite invariant. I'd expect one of:

  • { ...options?.init, status }status constructor arg wins, init only contributes other ResponseInit fields. This matches the doc.
  • Type-level fix — narrow init?: Omit<ResponseInit, "status"> in HttpErrorOptions so this can't be expressed.

Whichever you pick, please add a test asserting error.status === error.init.status after passing init: { status: <different> }.

Comment thread http/unstable_error.ts
Comment on lines +144 to +150
/**
* Constructs a new instance.
*
* @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
*/
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fibibot's nit #2 was partially addressed (duplicated description gone) — but the three @param lines here just restate the class-level @param lines verbatim. The constructor JSDoc can be dropped entirely; TypeDoc/LSP will show the class doc on new HttpError(...). Keeping it adds drift risk (the @param lines are now redundant copies).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 3 @param lines are here because of the doc lint checker requires them in constructor JSDocs. Perhaps, this should be changed.

Comment thread http/unstable_error_test.ts Outdated
import { assertEquals, assertInstanceOf } from "@std/assert";
import { HttpError } from "./unstable_error.ts";

Deno.test("HttpError initialises with correct defaults", () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fibibot's nit #3 wasn't addressed in the "Apply suggestions" commit — the test bodies were flattened out of the parent Deno.test("HttpError", t => ...) wrapper, but the names still read like step labels ("initialises with correct defaults") rather than the repo's symbolName() does X when Y convention. Suggested rewrites:

  • "HttpError initialises with correct defaults""new HttpError() defaults message to STATUS_TEXT[status]" (or split into "... sets status", "... defaults message", "... seeds init.status")
  • "HttpError initialises with custom properties""new HttpError() forwards cause and init from options"

(error.init.headers as Record<string, string>)["WWW-Authenticate"],
'Basic realm="Secure Area"',
);
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing coverage: an explicit test for the override semantics of options.init.status (see comment on unstable_error.ts:159). Whichever way that contract resolves, a test is the right artifact to lock it down so it doesn't silently regress later.

@iuioiua iuioiua requested a review from bartlomieju May 26, 2026 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants