Repository navigation
Description
Package
http2_adapter
Version
2.9.0
Operating-System
Android, iOS, MacOS, Linux, Windows
Adapter
Http2Adapter
Output of flutter doctor -v
Dart Version
No response
Steps to Reproduce
Part 1 — receiveTimeout does not cover response-body streaming:
- Set up an HTTP/2 server that responds with headers immediately, then stalls indefinitely mid-body (sends some bytes, then never closes the stream and never sends more data).
- Issue a request through
Http2AdapterwithreceiveTimeout: Duration(seconds: 5). - Observe the request never times out, even though the response body never completes.
final dio = Dio(BaseOptions(receiveTimeout: const Duration(seconds: 5)))
..httpClientAdapter = Http2Adapter(ConnectionManager());
// against a server that sends headers + partial body, then hangs forever
await dio.get<List<int>>('https://stalls-mid-body.example', options: Options(responseType: ResponseType.bytes));
// never throws DioException.receiveTimeout, hangs indefinitelyPart 2 — graceful GOAWAY(NO_ERROR) is reported as a connection error:
- Set up an HTTP/2 server (or use a real one that does rolling restarts) that, after serving some requests successfully, sends
GOAWAYwith error codeNO_ERRORas part of a graceful shutdown, then closes the socket. - Issue a request on that connection around the time of the GOAWAY.
- Observe the in-flight/next request surfaces as a hard error via
TransportConnectionException, rather than being silently retried on a fresh connection.
Expected Result
- Part 1:
receiveTimeoutshould apply to the entire response reception — including the body-streaming phase — consistent with its documented semantics ("time between two data chunks," matchingIOHttpClientAdapter's behavior) and consistent with what a caller reasonably expects from a timeout meant to bound how long a request can hang. - Part 2: A
GOAWAYwitherrorCode == ErrorCode.NO_ERRORis a normal, expected event (servers doing rolling restarts, connection-pool recycling, etc.) and should not surface as a user-visible error. The adapter should mark the connection dead and transparently retry the affected request on a new connection, the same way it already recovers from a stale/expired cached connection.
Actual Result
Part 1: In plugins/http2_adapter/lib/src/http2_adapter.dart, responseCompleter.complete() fires as soon as the :status header is observed (inside the HeadersStreamMessage branch of the stream.incomingMessages.listen handler). receiveTimeout wraps responseCompleter.future only — once that completes, the timeout is done and no longer protects anything. Subsequent DataStreamMessage chunks flow into responseSink with no timeout at all. A server that sends headers, a few bytes, and then never sends anything else (and never closes the stream) will hang the download forever regardless of receiveTimeout.
Part 2: The same listen call's onError handler treats every TransportConnectionException identically:
onError: (Object error, StackTrace stackTrace) {
if (error is TransportConnectionException) {
connectionManager.removeConnection(transport);
}
if (!responseCompleter.isCompleted) {
responseCompleter.completeError(error, stackTrace);
} else {
responseSink.addError(error, stackTrace);
}
...
}It correctly evicts the dead connection from the pool, but always propagates the error to the caller — even when error.errorCode == ErrorCode.NO_ERROR (confirmed available: TransportConnectionException in package:http2's lib/transport.dart carries a public final int errorCode field, constructed as TransportConnectionException(this.errorCode, String details)). There is no branch anywhere in the file that inspects errorCode.
This matches the pattern in three long-open upstream dart-lang/http issues that describe exactly this symptom from the server side:
#1913,#1917,#1364(oldest opened 2019-12-30, still open) — routineGOAWAY NO_ERRORfrom server-side connection recycling being surfaced as a client error instead of being handled transparently.
Solution Brainstorm (proposed fix)
Part 1 fix — track "time since last received chunk" (or "time since request start," whichever matches dio's documented receiveTimeout semantics for other adapters) independently of responseCompleter:
Timer? chunkTimer;
void resetChunkTimer() {
chunkTimer?.cancel();
if (receiveTimeout > Duration.zero) {
chunkTimer = Timer(receiveTimeout, () {
responseSubscription.cancel();
responseSink.addError(DioException.receiveTimeout(timeout: receiveTimeout, requestOptions: options));
responseSink.close();
});
}
}
// call resetChunkTimer() on every DataStreamMessage, and cancel it in onDone/onError.Part 2 fix — in the onError branch, special-case the graceful shutdown:
onError: (Object error, StackTrace stackTrace) {
if (error is TransportConnectionException) {
connectionManager.removeConnection(transport);
if (error.errorCode == ErrorCode.NO_ERROR && !_requestHasStartedReceivingBody) {
// retry transparently on a fresh connection, same code path used
// for a connection that was already dead when picked from the pool
return _retryOnNewConnection(options, redirects, ...);
}
}
...
}The exact retry mechanics should mirror whatever getConnection's existing dead-connection-recovery path already does (connection_manager_imp.dart's getConnection/_connect), rather than introducing a second retry mechanism. If the GOAWAY arrives after the response body has already started streaming to the caller, retrying is not safe/possible (partial data already delivered) — that case should still propagate as an error, hence the _requestHasStartedReceivingBody guard sketch above; the real condition to check is probably "has any DataStreamMessage been forwarded to responseSink yet," which the existing needResponse/statusCode state already tracks.
Additional context
Both parts were found while auditing Http2Adapter for known-issue coverage as part of a broader dio-adapter research pass; happy to help validate a fix against a local http2-speaking test server if useful (the reproduction server for Part 1 just needs to send headers + a short body chunk and then stop, with no further writes/closes — trivial to script with package:http2's ServerTransportConnection).
Activity