Skip to main content

Fetch Needs Error Codes

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) and H3_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) and H3_NO_ERROR (HTTP/3): Graceful closure. Nothing went wrong. Used in GOAWAY frames when the server is shutting down cleanly.

  • INTERNAL_ERROR (HTTP/2) and H3_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.

CodeMeaningHTTP/2 SourceHTTP/3 Source
ERR_HTTP_REQUEST_REJECTEDServer did not process the request. Safe to retry.REFUSED_STREAM (0x07)H3_REQUEST_REJECTED (0x010b)
ERR_HTTP_REQUEST_CANCELLEDRequest or response was intentionally cancelled. May have been partially processed.CANCEL (0x08)H3_REQUEST_CANCELLED (0x010c)
ERR_HTTP_INTERNAL_ERRORInternal error in the remote HTTP stack.INTERNAL_ERROR (0x02)H3_INTERNAL_ERROR (0x0102)
ERR_HTTP_STREAM_RESETGeneric stream reset. Used when the specific code doesn't map to a more specific abstract code, or is unrecognized.Any other RST_STREAM codeAny 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:

CodeMeaningHTTP/2 SourceHTTP/3 Source
ERR_HTTP_GOAWAYServer is shutting down gracefully. Requests on streams above the last-stream-id were never processed and are safe to retry.GOAWAY frameGOAWAY frame
ERR_HTTP_PROTOCOL_ERRORProtocol-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_ERRORA 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:

CodeMeaningSource
ERR_HTTP_RESPONSE_RESETServer stopped sending the response body.RESET_STREAM on the response direction
ERR_HTTP_REQUEST_BODY_REJECTEDServer 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:

CodeMeaningSource
ERR_HTTP_DNS_RESOLUTIONDNS resolution failed.DNS lookup failure
ERR_HTTP_TLS_ERRORTLS handshake failed.TLS error
ERR_HTTP_CONNECTION_REFUSEDConnection was refused.TCP/QUIC connection failure
ERR_HTTP_CONNECTION_RESETConnection was reset.TCP reset, QUIC connection close
ERR_HTTP_TIMEOUTRequest 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).