Skip to content

fix(call): map GOAWAY NO_ERROR to UNAVAILABLE instead of UNKNOWN - #827

Closed
aki1770-del wants to merge 2 commits into
grpc:masterfrom
aki1770-del:fix/goaway-no-error-maps-to-unavailable-802
Closed

fix(call): map GOAWAY NO_ERROR to UNAVAILABLE instead of UNKNOWN#827
aki1770-del wants to merge 2 commits into
grpc:masterfrom
aki1770-del:fix/goaway-no-error-maps-to-unavailable-802

Conversation

@aki1770-del

Copy link
Copy Markdown

Problem

When a gRPC server (e.g. tonic) performs a graceful shutdown it sends a
GOAWAY frame with error code NO_ERROR (0). The Dart http2 library
surfaces this as:

TransportConnectionException(0, 'Connection is being forcefully terminated.')

on every active stream. In ClientCall._onResponseError, this exception
has no special handling and falls through to:

_responseError(GrpcError.unknown(error.toString()), stackTrace);

GrpcError.unknown is code 2 / UNKNOWN — not retryable by standard
gRPC retry middleware. Callers see an opaque UNKNOWN error instead of
the retryable UNAVAILABLE that the situation warrants.

Closes #802.

Fix

Import TransportConnectionException from the http2 package and add
an explicit check in _onResponseError:

import 'package:http2/transport.dart' show TransportConnectionException;

void _onResponseError(Object error, StackTrace stackTrace) {
  if (error is GrpcError) {
    _responseError(error, stackTrace);
    return;
  }
  // GOAWAY with NO_ERROR (errorCode 0) = graceful server shutdown.
  // Map to UNAVAILABLE so retry middleware can reconnect transparently.
  if (error is TransportConnectionException && error.errorCode == 0) {
    _responseError(
      GrpcError.unavailable('Server initiated graceful shutdown: $error'),
      stackTrace,
    );
    return;
  }
  _responseError(GrpcError.unknown(error.toString()), stackTrace);
}

Non-zero errorCode values (real connection failures) retain the
existing UNKNOWN mapping.

Verification

dart analyze lib/src/client/call.dart
# Analyzing lib/src/client/call.dart... No issues found.

AI-assisted — authored with Claude, reviewed by Komada.

When a tonic server performs a graceful shutdown it sends GOAWAY with
error code NO_ERROR (0). The Dart http2 library surfaces this as
TransportConnectionException(0, 'Connection is being forcefully
terminated.') on each active stream. _onResponseError had no handling
for this type, so it fell through to GrpcError.unknown — a non-retryable
error that surfaces to callers as code 2 / UNKNOWN.

Add a TransportConnectionException import and detect errorCode == 0
(NO_ERROR) in _onResponseError, mapping it to GrpcError.unavailable
instead. UNAVAILABLE is the correct semantic (server temporarily
unavailable, retry on new connection) and activates standard retry
middleware. Non-zero error codes retain the existing UNKNOWN mapping.

Fixes grpc#802.

Co-Authored-By: Claude and aki1770-del <aki1770@gmail.com>
…ping (grpc#802)

Inject TransportConnectionException(errorCode: 0) via mock transport stream
and assert the response stream emits GrpcError.unavailable (StatusCode 14),
not GrpcError.unknown (StatusCode 2).

A second test verifies the non-zero errorCode path still produces a GrpcError.

Co-Authored-By: Claude and aki1770-del <aki1770@gmail.com>
@pull-request-size pull-request-size Bot added size/L and removed size/S labels Apr 4, 2026
@aki1770-del

Copy link
Copy Markdown
Author

Hello — following up on this PR opened 24 days ago. CI is green and there has been no review activity yet; happy to address any feedback when a maintainer has bandwidth.

The change unblocks transparent retry on graceful server shutdown: GOAWAY with NO_ERROR currently surfaces as UNKNOWN, which standard gRPC retry middleware does not retry, so callers see opaque failures during routine server restarts (e.g. tonic). Mapping to UNAVAILABLE matches the situation and is gated to errorCode == 0 only.

A concrete downstream consumer is kuksa_dart_sdk (https://github.com/aki1770-del/kuksa_dart_sdk), a Dart/Flutter client for the Eclipse KUKSA Vehicle Abstraction Layer that streams from a databroker subject to graceful restarts.

Three options that may help:

  1. Happy to address specific review feedback inline.
  2. Happy to add a regression test if useful.
  3. Happy to close if no longer relevant.

Comment thread lib/src/client/call.dart
// Map to UNAVAILABLE so retry middleware can reconnect transparently
// instead of surfacing UNKNOWN to the caller. Any non-zero errorCode is a
// real connection failure and retains the UNKNOWN mapping below.
if (error is TransportConnectionException && error.errorCode == 0) {

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.

I think there are several issues with this fix:

  1. I think there is probably a bug in the http2 package - if GOAWAY with NO_ERROR is specified as graceful shutdown, then it should not translate into TransportConnectionException to begin with. I think the code in http2 package should change to reflect this, unless there is some strong reason to not do that. Though I have trouble seeing what that reason would be... Exceptions should be reserved for error situations.
  2. I think this is the wrong layer to check for HTTP/2 transport specific connection issues. This needs to happen in the Http2ClientConnection.
  3. I think connection going away should only be an error for unary calls which did not receive response, all other cases this is just a graceful shutdown of the underlying connection.

@aki1770-del

Copy link
Copy Markdown
Author

Agreed on all three points.

On (1): if GOAWAY with NO_ERROR is specified as graceful shutdown, surfacing it as TransportConnectionException mis-models the protocol — the fix belongs in dart-lang/http2, not here. The current raise site hardcodes a "forcefully terminated" message regardless of error code, so the structural mis-cast is upstream of grpc-dart entirely.

On (2): even as a defensive in-tree mapping, Http2ClientConnection is the right boundary, not ClientCall._onResponseError. Per-call code should already be receiving the post-mapped error.

On (3): mapping every connection-close to UNAVAILABLE is wrong for streaming and already-responded unary calls — for those it's just end-of-stream. Unary-without-response is the only case where UNAVAILABLE is unambiguously correct.

Plan: closing this PR. Opening an issue on dart-lang/http2 proposing graceful shutdown surface as a non-exception (or a non-error subtype). After that lands, any grpc-dart change becomes a small consumer in Http2ClientConnection scoped to the unary-no-response case.

One correction: the PR description says "Closes #802." It shouldn't — #802 reports errorCode 10 (CANCEL), this PR maps errorCode == 0 (NO_ERROR). Different code paths. #802 stays open.

@aki1770-del

Copy link
Copy Markdown
Author

Filed: dart-lang/http#1913

aki1770-del added a commit to aki1770-del/http that referenced this pull request Aug 18, 2026
…art-lang#1913)

When a peer sends GOAWAY with NO_ERROR and then closes the underlying
transport, Connection._terminate() is invoked with
causedByTransportError=true and previously surfaced
TransportConnectionException("Connection is being forcefully
terminated.") to all sub-components — the same exception text used for
genuine forceful-termination paths. Consumers (notably grpc-dart, see
the referenced grpc/grpc-dart#827 attempt) could not distinguish a
clean peer-initiated shutdown from an actual fault without inspecting
the exception text.

This change detects the graceful-shutdown path in _terminate() (peer
has set FinishingPassive via processGoawayFrame + _finishing(false))
and surfaces a distinct exception under that condition:

  TransportConnectionException(
    errorCode: ErrorCode.NO_ERROR,
    message: 'Connection gracefully closed by peer.',
  )

Forceful-termination paths continue to surface the prior message
("Connection is being forcefully terminated.") unchanged. Consumers
can distinguish either via .errorCode == NO_ERROR or the new message
text.

Per the issue thread, brianquinlan voted for shape (b) — breaking but
cleaner. This implementation realizes that shape while keeping the
TransportConnectionException type stable so sub-component
onTerminated handlers (e.g. SettingsHandler null-check on error) keep
working without further refactoring.

Adds a regression test under transport-test verifying the
graceful-close path surfaces NO_ERROR + does not contain the forceful-
termination text.

Closes dart-lang#1913 (pending maintainer review of the chosen shape).
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.

Client-initiated GOAWAY closes active server-streaming; grpc-dart then surfaces UNKNOWN (HTTP/2 errorCode: 10)

2 participants