In my previous post, I outlined a number of gaps in the Fetch API that prevent JavaScript runtimes from expressing the full capabilities of modern HTTP. One of those gaps was typed stream resets. I want to dig into that one specifically, because there is an active TC39 proposal that could help close it, and because the timing is good: the proposal will be considered for stage advancement at the TC39 plenary later this month.
The problem
When a fetch() call fails due to a network-level problem, the Fetch spec
requires that the returned promise reject with a TypeError. Every kind of
network failure (DNS resolution failure, TLS handshake error, connection
refused, stream reset by server, redirect loop, offline client) produces the
same generic TypeError.. The spec deliberately destroys the useful diagnostic
information.
This is the code you write today:
try {
const response = await fetch(url, { method: 'POST', body });
} catch (e) {
// e is most likely a TypeError.
// Was the request received by the server? Was it processed?
// Is it safe to retry? Nobody knows.
}In a browser, this opacity is a necessary security feature. If a cross-origin
fetch() failure told you why it failed, you could probe the target's infrastructure:
does the host resolve? Is TLS configured? Is port 8443 open? The Fetch spec
prevents this by collapsing every failure into the same opaque signal. For the
browser threat model, hiding the exception detail is typically the correct
behavior.
But HTTP/2 and HTTP/3 have error signaling that goes beyond "something went
wrong." When a server resets an HTTP/2 stream, it sends an RST_STREAM frame
carrying a 32-bit error code. HTTP/3, built on QUIC, has two independent
mechanisms: RESET_STREAM (the sender abruptly stops sending) and STOP_SENDING
(the receiver tells the sender to stop). Both carry application-level error
codes defined by the HTTP/3 spec.
These codes have specific semantics that matter to application behavior. The Fetch spec discards all of them.
Error codes that matter
HTTP/2 (RFC 9113) defines
fourteen error codes for RST_STREAM. HTTP/3
(RFC 9114) defines sixteen.
Most of them are protocol plumbing that applications don't need to see. But a
few carry semantic information that directly affects what the application should
do next.
REFUSED_STREAM (HTTP/2) and H3_REQUEST_REJECTED (HTTP/3)
The server is telling you that it did not process the request at all. RFC 9113 section 8.7 is explicit about the implications:
The REFUSED_STREAM error code can be included in a RST_STREAM frame to indicate that the stream is being closed prior to any processing having occurred. Any request that was sent on the reset stream can be safely retried.
Requests that have not been processed have not failed; clients MAY automatically retry them, even those with non-idempotent methods.
RFC 9114 section 4.1.1 says the same thing for HTTP/3:
The client can treat requests rejected by the server as though they had never been sent at all, thereby allowing them to be retried later.
This is an unambiguous retryability guarantee. The server is telling the client: I never saw your POST. Send it again.
With fetch however, a REFUSED_STREAM produces the same TypeError as a DNS
failure. The retryability guarantee is destroyed.
Other meaningful codes
-
CANCEL(HTTP/2) andH3_REQUEST_CANCELLED(HTTP/3): The stream is no longer needed, but the server may have partially processed the request. This is the "I changed my mind" signal. Not safe to blindly retry. -
NO_ERROR(HTTP/2) andH3_NO_ERROR(HTTP/3): Graceful closure. Nothing went wrong. Used inGOAWAYframes when the server is shutting down cleanly. -
INTERNAL_ERROR(HTTP/2) andH3_INTERNAL_ERROR(HTTP/3): Something broke server-side. Different from "rejected" (definitely not processed) and different from "cancelled" (intentionally abandoned).
These four categories (rejected, cancelled, no-error, internal-error) cover the
most common cases. In the current Fetch API, they are indistinguishable. All four
produce a TypeError with no additional information.
QUIC adds another dimension
HTTP/2's RST_STREAM is a single frame that terminates a stream in both
directions at once. QUIC, the transport beneath HTTP/3, has a more granular model.
Streams in QUIC are bidirectional, and each direction can be terminated
independently:
-
RESET_STREAM(RFC 9000 section 19.4): the sender abruptly terminates its sending side. When the server sends this on the response stream, it means "I'm done sending this response, here's why." It carries an application protocol error code. -
STOP_SENDING(RFC 9000 section 19.5): the receiver requests that the sender stop sending. When the server sends this on the request stream, it means "I don't want the rest of your upload." Also carries an application protocol error code.
These are different failure modes. "I stopped sending you the response" and "I
don't want the rest of your request body" have different recovery implications,
particularly for full-duplex streaming scenarios like gRPC bidirectional
streams. But the Fetch spec has no concept of directional stream termination.
Both produce the same undifferentiated TypeError.
What the Fetch spec actually does
Looking at the Fetch standard directly, the error model is a deliberate information-destroying funnel.
A "network error" is a
response with type "error", status 0, an empty header list, and no body. It
carries no error code, no error message, no reason field. Every network error is
structurally identical to every other network error.
The spec defines exactly one variation: an
"aborted network error"
is a network error with an aborted flag set. That is the only distinction the
spec makes.
When a network error reaches the fetch() API boundary, it becomes a TypeError
rejection. Specifically, the spec says (in the processResponse steps):
If response is a network error, then reject p with a TypeError and abort these steps.
That TypeError has no .code, no .reason, no protocol metadata. It is just
a TypeError.
The spec mentions RST_STREAM exactly three times, and all three are about
sending one, never about receiving one. The spec says "if the connection uses
HTTP/2, then transmit an RST_STREAM frame" when aborting or redirecting. It
never specifies what should happen when the serve* sends an RST_STREAM to the
client. It doesn't reference HTTP/3's error signaling at all. GOAWAY appears zero
times.
The abort path is different and worth noting. When a fetch is cancelled via
AbortSignal, the caller can attach a structured reason that survives
serialization and is faithfully delivered back. The abort path is rich. The
network error path is empty.
The error code proposal
I have been championing a proposal in TC39
to add a standardized code property to Error objects. The proposal
is currently at Stage 1 and will be considered for Stage 2.7 advancement at the
next TC39 plenary.
The change is minimal. It extends the Error constructor options bag (the same
one that cause was added to in ES2022) to accept a code:
new TypeError("stream reset by server", {
code: "ERR_HTTP_REQUEST_REJECTED"
})The code property is defined on instances (not the prototype), accepts any
value (though strings are the dominant convention), is absent when not provided,
and matches the property descriptor semantics of cause exactly: non-enumerable,
writable, configurable.
The ecosystem has already adopted this pattern independently. I added the .code
property to Node.js errors back in 2017; there are now over 200 documented
ERR_* codes. Deno mirrors them in its Node.js compatibility layer. Bun mirrors
them and extends the same convention to its own native APIs. Libraries like axios,
Firebase, Stripe, Prisma, the AWS SDK, and many others all set .code on their
errors. This proposal just blesses the property at the language level so that it
has defined semantics in the constructor and doesn't need to be monkey-patched
after construction.
The property would survive structured cloning and cross-realm transfer, which is
directly relevant to how the Fetch spec serializes abort reasons via
StructuredSerialize and StructuredDeserialize.
How this helps Fetch
If TypeError can carry a .code, the Fetch spec can use it to propagate
protocol error information through the existing error surface without introducing
new error types, new API surface on Response, or changes to the promise
resolution model.
Handling a rejected fetch becomes:
try {
const response = await fetch(url, { method: 'POST', body });
} catch (e) {
switch (e.code) {
case 'ERR_HTTP_REQUEST_REJECTED':
// Server guarantees: request was not processed. Safe to retry.
return retry(url, { method: 'POST', body });
case 'ERR_HTTP_REQUEST_CANCELLED':
// Server may have partially processed. Not safe to blindly retry.
break;
case 'ERR_HTTP_STREAM_RESET':
// Generic stream reset. Cause might have protocol details.
break;
default:
// No code, or a code we don't recognize. Same behavior as today.
throw e;
}
}Code that doesn't check .code is completely unaffected. The TypeError is the
same TypeError. The promise rejection is the same rejection. The only
difference is that the error now optionally carries a machine-readable identifier
for the failure condition.
The cause property (already in ES2022) can carry the protocol-level detail for
applications that need it:
new TypeError("stream reset by server", {
code: "ERR_HTTP_REQUEST_REJECTED",
cause: {
protocol: "h2",
errorCode: 0x07,
errorName: "REFUSED_STREAM"
}
})Granted, there's some disagreement about whether cause should be used for
this kind of metadata. I'm on the fence about it. Some prefer cause to only
ever be another Error object, not a generic metadata bag. The point is here
that there are options for carrying the protocol-level detail if an implementation
needs it.
A code taxonomy
The abstract codes need to cover the breadth of failure conditions that HTTP/2 and HTTP/3 can express, while remaining protocol-version-independent. We'd need to define the taxonomy.
Stream-level errors
Mapping to RST_STREAM (HTTP/2) and RESET_STREAM / STOP_SENDING (HTTP/3) frames.
| Code | Meaning | HTTP/2 Source | HTTP/3 Source |
|---|---|---|---|
ERR_HTTP_REQUEST_REJECTED | Server did not process the request. Safe to retry. | REFUSED_STREAM (0x07) | H3_REQUEST_REJECTED (0x010b) |
ERR_HTTP_REQUEST_CANCELLED | Request or response was intentionally cancelled. May have been partially processed. | CANCEL (0x08) | H3_REQUEST_CANCELLED (0x010c) |
ERR_HTTP_INTERNAL_ERROR | Internal error in the remote HTTP stack. | INTERNAL_ERROR (0x02) | H3_INTERNAL_ERROR (0x0102) |
ERR_HTTP_STREAM_RESET | Generic stream reset. Used when the specific code doesn't map to a more specific abstract code, or is unrecognized. | Any other RST_STREAM code | Any other application error code |
These four cover the most common cases. ERR_HTTP_REQUEST_REJECTED is the
high-value one because it unlocks safe automatic retry of non-idempotent
requests.
Connection-level errors
Mapping to GOAWAY frames and connection-level failures that affect in-flight requests:
| Code | Meaning | HTTP/2 Source | HTTP/3 Source |
|---|---|---|---|
ERR_HTTP_GOAWAY | Server is shutting down gracefully. Requests on streams above the last-stream-id were never processed and are safe to retry. | GOAWAY frame | GOAWAY frame |
ERR_HTTP_PROTOCOL_ERROR | Protocol-level violation. The connection or stream was terminated due to a framing or protocol error. | PROTOCOL_ERROR (0x01) | H3_GENERAL_PROTOCOL_ERROR (0x0101) |
ERR_HTTP_CONNECT_ERROR | A CONNECT tunnel was reset or abnormally closed. | CONNECT_ERROR (0x0a) | H3_CONNECT_ERROR (0x010f) |
ERR_HTTP_GOAWAY is interesting because it carries the same retryability
semantics as ERR_HTTP_REQUEST_REJECTED for requests above the
last-stream-id, but the mechanism is connection-scoped rather than stream-scoped.
Note that the last-stream-id is not exposed here... we'd need to figure out how to express that also.
Directional reset codes
For HTTP/3 over QUIC, where the send and receive directions of a stream can be terminated independently:
| Code | Meaning | Source |
|---|---|---|
ERR_HTTP_RESPONSE_RESET | Server stopped sending the response body. | RESET_STREAM on the response direction |
ERR_HTTP_REQUEST_BODY_REJECTED | Server doesn't want the rest of the request body. | STOP_SENDING on the request direction |
These are meaningful for full-duplex streaming. "The server doesn't want my
upload anymore" and "the server stopped sending its response" are different
situations with different recovery strategies. For HTTP/2, which only has the
single RST_STREAM frame that terminates both directions simultaneously, these
would not apply.
Transport and pre-connection codes
These are below the HTTP layer and may raise cross-origin information leakage concerns in browsers:
| Code | Meaning | Source |
|---|---|---|
ERR_HTTP_DNS_RESOLUTION | DNS resolution failed. | DNS lookup failure |
ERR_HTTP_TLS_ERROR | TLS handshake failed. | TLS error |
ERR_HTTP_CONNECTION_REFUSED | Connection was refused. | TCP/QUIC connection failure |
ERR_HTTP_CONNECTION_RESET | Connection was reset. | TCP reset, QUIC connection close |
ERR_HTTP_TIMEOUT | Request timed out. | Various timeout conditions |
In a browser, these codes probably should not be exposed for cross-origin requests, because they reveal information about the target's network topology. Whether the host resolves, whether TLS is configured, whether a port is open, these are probes that the current opacity prevents. For same-origin requests, the concern is less acute. For server-side runtimes that don't have the CORS threat model, there is no reason to withhold them.
The Fetch spec could define these codes and mark them as "not exposed in cross-origin contexts" or limit them to an opt-in mode. Or a WinterTC extension spec could define the full set while the Fetch spec limits itself to the stream-level codes, which are safe to expose because they only occur after a connection has been established and the server has voluntarily communicated typed information to the client.
The breadth argument
The taxonomy above is not trying to expose every possible protocol error code
to JavaScript. Most of the HTTP/2 and HTTP/3 error codes are protocol plumbing:
FLOW_CONTROL_ERROR, FRAME_SIZE_ERROR, COMPRESSION_ERROR, SETTINGS_TIMEOUT.
These matter to protocol implementors but not to application developers. The
abstract code set focuses on the codes that carry semantic meaning for
application behavior: is this retryable? Was it cancelled? Did the server
reject it? Was it a directional half-close?
If an application needs the raw protocol error code (for logging, for
protocol-aware proxies, for debugging) we would still need to figure out
how to expose that. The cause property is one option, but there may be others.
The taxonomy should start small and grow. The four stream-level codes alone cover the highest-value use cases.
Changes to fetch
The changes needed fall into a few categories:
- The network error concept itself,
- The internal algorithms that create network errors, and
- The API boundary that converts them to TypeErrors.
The network error concept
The network error definition currently specifies a response with type "error", status 0, empty headers, and null body. It carries no metadata.
The change: add an optional "network error code" field to the network error concept. Something like:
A network error is a response whose type is "error", status is 0, status message is the empty byte sequence, header list is empty, body is null, body info is a new response body info, and network error code is null.
And then:
A network error with code is a network error whose network error code is set to a given value.
The network error code would not be directly exposed to JavaScript. It would be
an internal field that gets converted to a .code property on the TypeError.
The algorithms that create network errors
The Fetch spec creates network errors in many places. Most of them don't need codes. DNS failure, connection refused, TLS error: these are all at the "obtain a connection" level, and in a browser context, those should remain opaque.
In "HTTP-network fetch", after headers have been received and data is being streamed, the algorithm currently has steps for handling abort/termination that end with "return a network error" or "error the stream with a TypeError." These steps need to be extended to detect the protocol error code from the underlying HTTP/2 or HTTP/3 implementation and thread it through.
For instance, the current spec says (paraphrasing the steps around the body streaming loop):
If aborted, then: if the connection uses HTTP/2, transmit an RST_STREAM frame. Return the appropriate network error.
This would need to become something like:
If the stream was reset by the server, let errorCode be the stream error code. Let networkErrorCode be the result of mapping errorCode to an abstract error code. Return a network error with code networkErrorCode.
Similarly, the body streaming steps that currently say:
Otherwise, if stream is readable, error stream with a TypeError.
Would need to account for a received stream reset:
Otherwise, if stream is readable, error stream with a TypeError whose code is the network error's network error code.
The terminate algorithm
The fetch controller's "terminate" algorithm currently just sets the state to "terminated" with no additional information. Compare this to the "abort" algorithm, which accepts an error, serializes it, and stores it as the "serialized abort reason."
The terminate path needs a similar slot. When a stream is reset by the server
(as opposed to cancelled by the caller), the reset code should be stored on
the fetch controller so it can be threaded through to the TypeError that
eventually reaches user code.
One approach: add a "termination reason" field to the fetch controller, parallel
to the existing "serialized abort reason." When terminating due to a stream reset,
store the error code. When the spec later constructs a TypeError for a
terminated (non-aborted) fetch, use the code:
Otherwise, if stream is readable, error stream with a TypeError whose code is fetchParams's controller's termination reason's code.
The fetch() API boundary
The fetch() function's processResponse steps currently say:
If response is a network error, then reject p with a TypeError and abort these steps.
With error codes, this becomes:
If response is a network error, then reject p with a TypeError. If response's network error code is non-null, the TypeError's code is response's network error code. Abort these steps.
Security scoping
Obviously, not all codes should be exposed in all contexts. The spec would need
a step that checks whether a given code is safe to expose. Stream-level codes
(ERR_HTTP_REQUEST_REJECTED, ERR_HTTP_REQUEST_CANCELLED, etc.) are safe because
they represent information the server chose to communicate after the connection
was established. Pre-connection codes (ERR_HTTP_DNS_RESOLUTION,
ERR_HTTP_TLS_ERROR) reveal infrastructure details and should be redacted:
If response's network error code is a pre-connection code and the request is cross-origin, set the TypeError's code to null.
Server-side runtimes that don't implement CORS can skip this check entirely, the same way they already skip CORS enforcement today.
The body stream case
The error codes are not only useful at the fetch() promise rejection level.
Errors can also occur after the response headers have arrived and the promise
has already resolved. In this case, the error surfaces on the response body's
ReadableStream.
The spec currently errors the body stream with a bare TypeError when the
connection is terminated (non-aborted) during body transfer. With error codes,
this TypeError would carry the same .code:
const response = await fetch(url); // resolves fine, headers received
const reader = response.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
process(value);
}
} catch (e) {
if (e.code === 'ERR_HTTP_REQUEST_CANCELLED') {
// Server cancelled the response mid-stream.
}
}This matters for streaming use cases where you want to know why the stream ended prematurely. "Server cancelled" and "server error" and "connection lost" are all different situations.
Timing
The error code proposal will be considered for advancement at the TC39 plenary
later this month. As a Stage 1 proposal, the committee has agreed that the
problem is worth solving. The question now is whether the specific solution
(a .code property on the Error constructor options bag) is the right approach
and whether it is mature enough to advance.
The Fetch error reporting gap is a concrete case (and is one of the motivating
reasons I proposed the .code property in the first place). HTTP/2 and HTTP/3
have rich typed error signaling that implementations already receive at the protocol
layer.
The code property is a minimal, backwards-compatible mechanism for threading
that information through to application code.
If the proposal advances, the Fetch spec can be updated to use it. The taxonomy of codes can be defined either by the Fetch spec itself or by a separate extension spec (possibly by WinterTC).