Repository: cfug/dio (dart)

Issue:
Download  receiveTimeout 
### Package

dio

### Version

5.3.3

### Operating-System

Android

### Output of `flutter doctor -v`

```shell
┌─────────────────────────────────────────────────────────┐
│ A new version of Flutter is available!                  │
│                                                         │
│ To update to the latest version, run "flutter upgrade". │
└─────────────────────────────────────────────────────────┘
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.13.6, on macOS 14.0 23A344 darwin-arm64, locale
    zh-Hans-CN)
[✓] Android toolchain - develop for Android devices (Android SDK version 32.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.0)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2022.3)
[✓] IntelliJ IDEA Ultimate Edition (version 2023.1.1)
[✓] VS Code (version 1.77.1)
[✓] Connected device (4 available)
```


### Dart Version

_No response_

### Steps to Reproduce

In previous versions, it was only necessary to set the receiveTimeout of the Download Options to 0 to download without timeout. Now, after changing to the Duration type, the time cannot be unlimited

```dart
Future downloadFile(urlPath, savePath,
      {CancelToken? cancelToken, ProgressCallback? onReceiveProgress}) async {
    Response? response;
    response = await dio.download(urlPath, savePath,
        cancelToken: cancelToken,
        options: Options(receiveTimeout: Duration(seconds: 0)),
        onReceiveProgress: onReceiveProgress ??
            (int count, int total) {
              logI("$count $total");
            });
    return response;
  }
```

### Expected Result

download success

### Actual Result

[ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: DioException [receive timeout]: The request took longer than 0:00:00.000000 to receive data. It was aborted.

Pull request description:
Resolves #2022.

### New Pull Request Checklist

- [x] I have read the [Documentation](https://pub.dev/documentation/dio/latest/)
- [x] I have searched for a similar pull request in the [project](https://github.com/cfug/dio/pulls) and found none
- [x] I have updated this branch with the latest `main` branch to avoid conflicts (via merge from master or rebase)
- [x] I have added the required tests to prove the fix/feature I'm adding
- [x] I have updated the documentation (if necessary)
- [x] I have run the tests without failures
- [x] I have updated the `CHANGELOG.md` in the corresponding package

### Additional context and info (if any)

I've found that the `IOHttpClientAdapter` uses `Stopwatch` to integrate with `receiveTimeout`, which might cause infinity awaits if the stream has no response forever. This might be the root cause of #1739.
https://github.com/cfug/dio/blob/78f3813a8d8948887198ef628e5a2e2039489b03/dio/lib/src/adapters/io_adapter.dart#L194-L200

Patch:
diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md
index b361c1b93..7c05a1bff 100644
--- a/dio/CHANGELOG.md
+++ b/dio/CHANGELOG.md
@@ -6,8 +6,9 @@ See the [Migration Guide][] for the complete breaking changes list.**
 ## Unreleased
 
 - Raise warning for `Map`s other than `Map<String, dynamic>` when encoding request data.
-- Improve exception messages
-- Allow `ResponseDecoder` and `RequestEncoder` to be async
+- Improve exception messages.
+- Allow `ResponseDecoder` and `RequestEncoder` to be async.
+- Ignores `Duration.zero` timeouts.
 
 ## 5.3.3
 
diff --git a/dio/lib/src/adapters/browser_adapter.dart b/dio/lib/src/adapters/browser_adapter.dart
index 399113c76..a4601ca52 100644
--- a/dio/lib/src/adapters/browser_adapter.dart
+++ b/dio/lib/src/adapters/browser_adapter.dart
@@ -58,15 +58,11 @@ class BrowserHttpClientAdapter implements HttpClientAdapter {
       }
     });
 
-    final connectTimeout = options.connectTimeout;
-    final receiveTimeout = options.receiveTimeout;
-    int xhrTimeout = 0;
-    if (connectTimeout != null &&
-        receiveTimeout != null &&
-        receiveTimeout > Duration.zero) {
-      xhrTimeout = (connectTimeout + receiveTimeout).inMilliseconds;
-      xhr.timeout = xhrTimeout;
-    }
+    final sendTimeout = options.sendTimeout ?? Duration.zero;
+    final connectTimeout = options.connectTimeout ?? Duration.zero;
+    final receiveTimeout = options.receiveTimeout ?? Duration.zero;
+    final xhrTimeout = (connectTimeout + receiveTimeout).inMilliseconds;
+    xhr.timeout = xhrTimeout;
 
     final completer = Completer<ResponseBody>();
 
@@ -86,22 +82,20 @@ class BrowserHttpClientAdapter implements HttpClientAdapter {
     });
 
     Timer? connectTimeoutTimer;
-
-    final connectionTimeout = options.connectTimeout;
-    if (connectionTimeout != null) {
+    if (connectTimeout > Duration.zero) {
       connectTimeoutTimer = Timer(
-        connectionTimeout,
+        connectTimeout,
         () {
+          connectTimeoutTimer = null;
           if (completer.isCompleted) {
             // connectTimeout is triggered after the fetch has been completed.
             return;
           }
-
           xhr.abort();
           completer.completeError(
             DioException.connectionTimeout(
               requestOptions: options,
-              timeout: connectionTimeout,
+              timeout: connectTimeout,
             ),
             StackTrace.current,
           );
@@ -123,14 +117,12 @@ class BrowserHttpClientAdapter implements HttpClientAdapter {
         });
       }
 
-      final sendTimeout = options.sendTimeout;
-      if (sendTimeout != null) {
+      if (sendTimeout > Duration.zero) {
         final uploadStopwatch = Stopwatch();
         xhr.upload.onProgress.listen((event) {
           if (!uploadStopwatch.isRunning) {
             uploadStopwatch.start();
           }
-
           final duration = uploadStopwatch.elapsed;
           if (duration > sendTimeout) {
             uploadStopwatch.stop();
@@ -155,7 +147,7 @@ class BrowserHttpClientAdapter implements HttpClientAdapter {
         });
       }
     } else {
-      if (options.sendTimeout != null) {
+      if (sendTimeout > Duration.zero) {
         debugLog(
           'sendTimeout cannot be used without a request body to send',
           StackTrace.current,
@@ -176,18 +168,16 @@ class BrowserHttpClientAdapter implements HttpClientAdapter {
         connectTimeoutTimer = null;
       }
 
-      final receiveTimeout = options.receiveTimeout;
-      if (receiveTimeout != null) {
+      if (receiveTimeout > Duration.zero) {
         if (!downloadStopwatch.isRunning) {
           downloadStopwatch.start();
         }
-
         final duration = downloadStopwatch.elapsed;
         if (duration > receiveTimeout) {
           downloadStopwatch.stop();
           completer.completeError(
             DioException.receiveTimeout(
-              timeout: options.receiveTimeout!,
+              timeout: receiveTimeout,
               requestOptions: options,
             ),
             StackTrace.current,
@@ -195,6 +185,7 @@ class BrowserHttpClientAdapter implements HttpClientAdapter {
           xhr.abort();
         }
       }
+
       if (options.onReceiveProgress != null) {
         if (event.loaded != null && event.total != null) {
           options.onReceiveProgress!(event.loaded!, event.total!);
@@ -218,17 +209,27 @@ class BrowserHttpClientAdapter implements HttpClientAdapter {
     });
 
     xhr.onTimeout.first.then((_) {
+      final isConnectTimeout = connectTimeoutTimer != null;
       if (connectTimeoutTimer != null) {
         connectTimeoutTimer?.cancel();
       }
       if (!completer.isCompleted) {
-        completer.completeError(
-          DioException.receiveTimeout(
-            timeout: Duration(milliseconds: xhrTimeout),
-            requestOptions: options,
-          ),
-          StackTrace.current,
-        );
+        if (isConnectTimeout) {
+          completer.completeError(
+            DioException.connectionTimeout(
+              timeout: connectTimeout,
+              requestOptions: options,
+            ),
+          );
+        } else {
+          completer.completeError(
+            DioException.receiveTimeout(
+              timeout: Duration(milliseconds: xhrTimeout),
+              requestOptions: options,
+            ),
+            StackTrace.current,
+          );
+        }
       }
     });
 
diff --git a/dio/lib/src/adapters/io_adapter.dart b/dio/lib/src/adapters/io_adapter.dart
index 65e9ac6cf..35a1b781b 100644
--- a/dio/lib/src/adapters/io_adapter.dart
+++ b/dio/lib/src/adapters/io_adapter.dart
@@ -92,7 +92,7 @@ class IOHttpClientAdapter implements HttpClientAdapter {
     late HttpClientRequest request;
     try {
       final connectionTimeout = options.connectTimeout;
-      if (connectionTimeout != null) {
+      if (connectionTimeout != null && connectionTimeout > Duration.zero) {
         request = await reqFuture.timeout(
           connectionTimeout,
           onTimeout: () {
@@ -116,11 +116,19 @@ class IOHttpClientAdapter implements HttpClientAdapter {
       });
     } on SocketException catch (e) {
       if (e.message.contains('timed out')) {
+        final Duration effectiveTimeout;
+        if (options.connectTimeout != null &&
+            options.connectTimeout! > Duration.zero) {
+          effectiveTimeout = options.connectTimeout!;
+        } else if (httpClient.connectionTimeout != null &&
+            httpClient.connectionTimeout! > Duration.zero) {
+          effectiveTimeout = httpClient.connectionTimeout!;
+        } else {
+          effectiveTimeout = Duration.zero;
+        }
         throw DioException.connectionTimeout(
           requestOptions: options,
-          timeout: options.connectTimeout ??
-              httpClient.connectionTimeout ??
-              Duration.zero,
+          timeout: effectiveTimeout,
           error: e,
         );
       }
@@ -139,7 +147,7 @@ class IOHttpClientAdapter implements HttpClientAdapter {
       // Transform the request data.
       Future<dynamic> future = request.addStream(requestStream);
       final sendTimeout = options.sendTimeout;
-      if (sendTimeout != null) {
+      if (sendTimeout != null && sendTimeout > Duration.zero) {
         future = future.timeout(
           sendTimeout,
           onTimeout: () {
@@ -157,7 +165,7 @@ class IOHttpClientAdapter implements HttpClientAdapter {
     final stopwatch = Stopwatch()..start();
     Future<HttpClientResponse> future = request.close();
     final receiveTimeout = options.receiveTimeout;
-    if (receiveTimeout != null) {
+    if (receiveTimeout != null && receiveTimeout > Duration.zero) {
       future = future.timeout(
         receiveTimeout,
         onTimeout: () {
@@ -195,7 +203,9 @@ class IOHttpClientAdapter implements HttpClientAdapter {
           stopwatch.stop();
           final duration = stopwatch.elapsed;
           final receiveTimeout = options.receiveTimeout;
-          if (receiveTimeout != null && duration > receiveTimeout) {
+          if (receiveTimeout != null &&
+              receiveTimeout > Duration.zero &&
+              duration > receiveTimeout) {
             sink.addError(
               DioException.receiveTimeout(
                 timeout: receiveTimeout,
@@ -228,8 +238,11 @@ class IOHttpClientAdapter implements HttpClientAdapter {
   }
 
   HttpClient _configHttpClient(Duration? connectionTimeout) {
-    return (_cachedHttpClient ??= _createHttpClient())
-      ..connectionTimeout = connectionTimeout;
+    _cachedHttpClient ??= _createHttpClient();
+    if (connectionTimeout != null && connectionTimeout > Duration.zero) {
+      _cachedHttpClient!.connectionTimeout = connectionTimeout;
+    }
+    return _cachedHttpClient!;
   }
 
   @override
diff --git a/dio/lib/src/options.dart b/dio/lib/src/options.dart
index d455afc36..44038d380 100644
--- a/dio/lib/src/options.dart
+++ b/dio/lib/src/options.dart
@@ -107,7 +107,7 @@ mixin OptionsMixin {
   /// [Dio] will throw the [DioException] with
   /// [DioExceptionType.connectionTimeout] type when time out.
   ///
-  /// `null` meanings no timeout limit.
+  /// `null` or `Duration.zero` meanings no timeout limit.
   Duration? get connectTimeout => _connectTimeout;
   Duration? _connectTimeout;
 
@@ -363,7 +363,7 @@ class Options {
   /// [Dio] will throw the [DioException] with
   /// [DioExceptionType.sendTimeout] type when timed out.
   ///
-  /// `null` meanings no timeout limit.
+  /// `null` or `Duration.zero` meanings no timeout limit.
   Duration? get sendTimeout => _sendTimeout;
   Duration? _sendTimeout;
 
@@ -382,7 +382,7 @@ class Options {
   /// [Dio] will throw the [DioException] with
   /// [DioExceptionType.receiveTimeout] type when time out.
   ///
-  /// `null` meanings no timeout limit.
+  /// `null` or `Duration.zero` meanings no timeout limit.
   Duration? get receiveTimeout => _receiveTimeout;
   Duration? _receiveTimeout;
 
diff --git a/plugins/http2_adapter/CHANGELOG.md b/plugins/http2_adapter/CHANGELOG.md
index 8e3ef62ce..d8333501c 100644
--- a/plugins/http2_adapter/CHANGELOG.md
+++ b/plugins/http2_adapter/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 - Implement `sendTimeout` and `receiveTimeout` for the adapter.
 - Fix redirect not working when requestStream is null.
+- Ignores `Duration.zero` timeouts.
 
 ## 2.3.1+1
 
diff --git a/plugins/http2_adapter/lib/src/connection_manager_imp.dart b/plugins/http2_adapter/lib/src/connection_manager_imp.dart
index 1ef0bd5d7..0a8edaa4c 100644
--- a/plugins/http2_adapter/lib/src/connection_manager_imp.dart
+++ b/plugins/http2_adapter/lib/src/connection_manager_imp.dart
@@ -85,7 +85,7 @@ class _ConnectionManager implements ConnectionManager {
       if (e.osError == null) {
         if (e.message.contains('timed out')) {
           throw DioException.connectionTimeout(
-            timeout: options.connectTimeout!,
+            timeout: options.connectTimeout ?? Duration.zero,
             requestOptions: options,
           );
         }
@@ -135,11 +135,16 @@ class _ConnectionManager implements ConnectionManager {
     RequestOptions options,
     ClientSetting clientConfig,
   ) async {
-    if (clientConfig.proxy == null) {
+    final timeout = (options.connectTimeout ?? Duration.zero) > Duration.zero
+        ? options.connectTimeout!
+        : null;
+    final proxy = clientConfig.proxy;
+
+    if (proxy == null) {
       return SecureSocket.connect(
         target.host,
         target.port,
-        timeout: options.connectTimeout,
+        timeout: timeout,
         context: clientConfig.context,
         onBadCertificate: clientConfig.onBadCertificate,
         supportedProtocols: ['h2'],
@@ -147,13 +152,12 @@ class _ConnectionManager implements ConnectionManager {
     }
 
     final proxySocket = await Socket.connect(
-      clientConfig.proxy!.host,
-      clientConfig.proxy!.port,
-      timeout: options.connectTimeout,
+      proxy.host,
+      proxy.port,
+      timeout: timeout,
     );
 
-    final String credentialsProxy =
-        base64Encode(utf8.encode(clientConfig.proxy!.userInfo));
+    final String credentialsProxy = base64Encode(utf8.encode(proxy.userInfo));
 
     // Create http tunnel proxy https://www.ietf.org/rfc/rfc2817.txt
 
diff --git a/plugins/http2_adapter/lib/src/http2_adapter.dart b/plugins/http2_adapter/lib/src/http2_adapter.dart
index e235d0e4a..0c57d62da 100644
--- a/plugins/http2_adapter/lib/src/http2_adapter.dart
+++ b/plugins/http2_adapter/lib/src/http2_adapter.dart
@@ -92,8 +92,8 @@ class Http2Adapter implements HttpClientAdapter {
       final requestStreamFuture = requestStream!.listen((data) {
         stream.outgoingMessages.add(DataStreamMessage(data));
       }).asFuture();
-      final sendTimeout = options.sendTimeout;
-      if (sendTimeout != null) {
+      final sendTimeout = options.sendTimeout ?? Duration.zero;
+      if (sendTimeout > Duration.zero) {
         await requestStreamFuture.timeout(
           sendTimeout,
           onTimeout: () {
@@ -158,8 +158,8 @@ class Http2Adapter implements HttpClientAdapter {
       cancelOnError: true,
     );
 
-    final receiveTimeout = options.receiveTimeout;
-    if (receiveTimeout != null) {
+    final receiveTimeout = options.receiveTimeout ?? Duration.zero;
+    if (receiveTimeout > Duration.zero) {
       await completer.future.timeout(
         receiveTimeout,
         onTimeout: () {


Test patch:
diff --git a/dio/test/timeout_test.dart b/dio/test/timeout_test.dart
index 7af397f78..367b7fd5e 100644
--- a/dio/test/timeout_test.dart
+++ b/dio/test/timeout_test.dart
@@ -70,4 +70,31 @@ void main() {
     } on DioException catch (_) {}
     expect(http.connectionTimeout?.inSeconds == 1, isTrue);
   }, testOn: 'vm');
+
+  test('ignores zero duration timeouts', () async {
+    final dio = Dio(
+      BaseOptions(
+        baseUrl: 'https://httpbun.com/',
+        connectTimeout: Duration.zero,
+        sendTimeout: Duration.zero,
+        receiveTimeout: Duration.zero,
+      ),
+    );
+    // Ignores zero duration timeouts from the base options.
+    await dio.get('/drip-lines?delay=1');
+    // Reset the base options.
+    dio.options
+      ..connectTimeout = Duration(seconds: 10)
+      ..sendTimeout = Duration(seconds: 10)
+      ..receiveTimeout = Duration(seconds: 10);
+    // Override with request options.
+    await dio.get(
+      '/drip-lines?delay=1',
+      options: Options(
+        sendTimeout: Duration.zero,
+        receiveTimeout: Duration.zero,
+      ),
+    );
+    await dio.get('/drip-lines?delay=1');
+  });
 }