Summary
new Response("", { status: 204 }) throws in perry and succeeds in bun:
Response constructor: Invalid response status code 204
204 is a perfectly valid status code, so the message misdescribes the cause. What perry is actually enforcing is the Fetch spec's null-body-status rule — but it enforces it against bun's behavior, and it reports it as if the code itself were out of range.
Measured (perry @ v0.5.1579 + #10356, vs bun 1.3.14)
| # |
cell |
bun |
perry |
|
| A |
new Response(null, {status:204}).status |
204 |
204 |
ok |
| B |
new Response("x", {status:204}).status |
204 |
THREW Invalid response status code 204 |
✗ |
| C |
new Response("x", {status:205}).status |
205 |
THREW Invalid response status code 205 |
✗ |
| D |
new Response("x", {status:304}).status |
304 |
THREW Invalid response status code 304 |
✗ |
| E |
new Response("x", {status:201}).status |
201 |
201 |
ok |
| F |
new Response("x", {status:599}).status |
599 |
599 |
ok |
| G |
Response.json({a:1}, {status:204}).status |
204 |
204 |
ok |
| 7 |
new Response("", {status:204}).status |
204 |
THREW Invalid response status code 204 |
✗ |
| 8 |
new Response(undefined, {status:204}).status |
204 |
204 |
ok |
| 9 |
new Response(null, {status:304}).status |
304 |
304 |
ok |
| 10 |
new Response("", {status:200}).status |
200 |
200 |
ok |
So the trigger is precisely a non-null body together with a null-body status (204, 205, 304). A null or undefined body with the same status is fine, and the same body with any other status is fine.
Cell 7 is the one that matters in practice: new Response("", { status: 204 }) is how you write "no content" when your handler returns a string unconditionally, and it is extremely common in HTTP server code. OpenCode serves its own TUI backend over HTTP.
Note cell G: Response.json(..., {status:204}) does not throw, while the constructor does. Whatever check the constructor applies, the static helper does not — so the two paths already disagree with each other inside perry.
Reproducer
// @ts-nocheck
const t = (n: string, f: () => any) => {
try { const v = f(); console.log(n, v === undefined ? "undefined" : JSON.stringify(v)) }
catch (e: any) { console.log(n, "THREW " + e.message) }
}
t("A Response(null,204)", () => new Response(null, { status: 204 }).status)
t("B Response('x',204)", () => new Response("x", { status: 204 }).status)
t("C Response('x',205)", () => new Response("x", { status: 205 }).status)
t("D Response('x',304)", () => new Response("x", { status: 304 }).status)
t("E Response('x',201)", () => new Response("x", { status: 201 }).status)
t("G Response.json(x,204)", () => Response.json({a:1}, { status: 204 }).status)
t("7 Response('',204)", () => new Response("", { status: 204 }).status)
t("8 Response(undefined,204)", () => new Response(undefined, { status: 204 }).status)
On which behavior is correct
The Fetch spec does say to throw a TypeError when a null-body status is paired with a non-null body, so perry's intent is defensible and bun is the lenient one here. Two things are wrong regardless of which side you pick:
- The message is wrong about the cause. "Invalid response status code 204" says the code is invalid. It is not — cell A proves perry itself accepts 204. The message should name the body/null-body-status conflict.
- perry contradicts itself.
Response.json(..., {status:204}) builds a response with a JSON body and a null-body status without complaint (cell G), so the rule is enforced on one construction path and not the other.
For the OpenCode compatibility goal the target is bun's behavior, but even taking the strict reading, cells G and 7 need to agree with each other.
Summary
new Response("", { status: 204 })throws in perry and succeeds in bun:204 is a perfectly valid status code, so the message misdescribes the cause. What perry is actually enforcing is the Fetch spec's null-body-status rule — but it enforces it against bun's behavior, and it reports it as if the code itself were out of range.
Measured (perry @ v0.5.1579 + #10356, vs bun 1.3.14)
new Response(null, {status:204}).status204204new Response("x", {status:204}).status204THREW Invalid response status code 204new Response("x", {status:205}).status205THREW Invalid response status code 205new Response("x", {status:304}).status304THREW Invalid response status code 304new Response("x", {status:201}).status201201new Response("x", {status:599}).status599599Response.json({a:1}, {status:204}).status204204new Response("", {status:204}).status204THREW Invalid response status code 204new Response(undefined, {status:204}).status204204new Response(null, {status:304}).status304304new Response("", {status:200}).status200200So the trigger is precisely a non-null body together with a null-body status (204, 205, 304). A
nullorundefinedbody with the same status is fine, and the same body with any other status is fine.Cell 7 is the one that matters in practice:
new Response("", { status: 204 })is how you write "no content" when your handler returns a string unconditionally, and it is extremely common in HTTP server code. OpenCode serves its own TUI backend over HTTP.Note cell G:
Response.json(..., {status:204})does not throw, while the constructor does. Whatever check the constructor applies, the static helper does not — so the two paths already disagree with each other inside perry.Reproducer
On which behavior is correct
The Fetch spec does say to throw a TypeError when a null-body status is paired with a non-null body, so perry's intent is defensible and bun is the lenient one here. Two things are wrong regardless of which side you pick:
Response.json(..., {status:204})builds a response with a JSON body and a null-body status without complaint (cell G), so the rule is enforced on one construction path and not the other.For the OpenCode compatibility goal the target is bun's behavior, but even taking the strict reading, cells G and 7 need to agree with each other.