diff --git a/.github/workflows/cronet.yml b/.github/workflows/cronet.yml index 83dfda0..25d7ddb 100644 --- a/.github/workflows/cronet.yml +++ b/.github/workflows/cronet.yml
@@ -38,7 +38,7 @@ with: distribution: 'zulu' java-version: '17' - - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 + - uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e with: channel: 'stable' - id: install
diff --git a/.github/workflows/cupertino.yml b/.github/workflows/cupertino.yml index e2d92d1..480c636 100644 --- a/.github/workflows/cupertino.yml +++ b/.github/workflows/cupertino.yml
@@ -39,7 +39,7 @@ os: [macos-13, macos-latest] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 + - uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e with: flutter-version: ${{ matrix.flutter-version }} channel: 'stable' @@ -71,7 +71,7 @@ flutter-version: ["3.24.0", "any"] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 + - uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e with: flutter-version: ${{ matrix.flutter-version }} channel: 'stable'
diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 1ebeee1..d8fd7a4 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml
@@ -252,7 +252,7 @@ os:ubuntu-latest;pub-cache-hosted os:ubuntu-latest - name: Setup Flutter SDK - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 + uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e with: channel: stable - id: checkout @@ -282,7 +282,7 @@ os:ubuntu-latest;pub-cache-hosted os:ubuntu-latest - name: Setup Flutter SDK - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 + uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e with: channel: stable - id: checkout @@ -867,7 +867,7 @@ os:ubuntu-latest;pub-cache-hosted os:ubuntu-latest - name: Setup Flutter SDK - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 + uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e with: channel: stable - id: checkout @@ -904,7 +904,7 @@ os:ubuntu-latest;pub-cache-hosted os:ubuntu-latest - name: Setup Flutter SDK - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 + uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e with: channel: stable - id: checkout @@ -941,7 +941,7 @@ os:macos-latest;pub-cache-hosted os:macos-latest - name: Setup Flutter SDK - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 + uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e with: channel: stable - id: checkout @@ -968,7 +968,7 @@ runs-on: windows-latest steps: - name: Setup Flutter SDK - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 + uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e with: channel: stable - id: checkout
diff --git a/.github/workflows/okhttp.yaml b/.github/workflows/okhttp.yaml index b260cfe..286e890 100644 --- a/.github/workflows/okhttp.yaml +++ b/.github/workflows/okhttp.yaml
@@ -33,7 +33,7 @@ with: distribution: 'zulu' java-version: '17' - - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 + - uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e with: channel: 'stable' - id: install
diff --git a/pkgs/cronet_http/CHANGELOG.md b/pkgs/cronet_http/CHANGELOG.md index c55699e..51fe012 100644 --- a/pkgs/cronet_http/CHANGELOG.md +++ b/pkgs/cronet_http/CHANGELOG.md
@@ -2,6 +2,7 @@ * Add a new `CronetStreamedResponse` class that provides additional information about the HTTP response. +* Fix a Flutter warning by upgrading to Kotlin 1.18.10. ## 1.3.4
diff --git a/pkgs/cronet_http/example/android/settings.gradle b/pkgs/cronet_http/example/android/settings.gradle index b18e1a0..27029b3 100644 --- a/pkgs/cronet_http/example/android/settings.gradle +++ b/pkgs/cronet_http/example/android/settings.gradle
@@ -19,7 +19,7 @@ plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "8.6.0" apply false - id "org.jetbrains.kotlin.android" version "1.7.21" apply false + id "org.jetbrains.kotlin.android" version "1.8.10" apply false } include ":app"
diff --git a/pkgs/cronet_http/example/pubspec.yaml b/pkgs/cronet_http/example/pubspec.yaml index dfaa39a..87c8234 100644 --- a/pkgs/cronet_http/example/pubspec.yaml +++ b/pkgs/cronet_http/example/pubspec.yaml
@@ -29,3 +29,9 @@ flutter: uses-material-design: true + +# TODO(brianquinlan): Remove this when a release version of `package:http` +# supports abortable requests. +dependency_overrides: + http: + path: ../../http/
diff --git a/pkgs/cupertino_http/example/pubspec.yaml b/pkgs/cupertino_http/example/pubspec.yaml index 38f75b0..0d44ce4 100644 --- a/pkgs/cupertino_http/example/pubspec.yaml +++ b/pkgs/cupertino_http/example/pubspec.yaml
@@ -38,3 +38,9 @@ flutter: uses-material-design: true + +# TODO(brianquinlan): Remove this when a release version of `package:http` +# supports abortable requests. +dependency_overrides: + http: + path: ../../http/
diff --git a/pkgs/http/CHANGELOG.md b/pkgs/http/CHANGELOG.md index 73e036c..80d9110 100644 --- a/pkgs/http/CHANGELOG.md +++ b/pkgs/http/CHANGELOG.md
@@ -1,5 +1,11 @@ -## 1.4.1 +## 1.5.0-beta.2 +* Fixed a bug in `IOClient` where the `HttpClient`'s response stream was + cancelled after the response stream was completed. + +## 1.5.0-beta + +* Added support for aborting requests before they complete. * Clarify that some header names may not be sent/received. ## 1.4.0
diff --git a/pkgs/http/README.md b/pkgs/http/README.md index 07e4394..547cb64 100644 --- a/pkgs/http/README.md +++ b/pkgs/http/README.md
@@ -113,6 +113,92 @@ [new RetryClient]: https://pub.dev/documentation/http/latest/retry/RetryClient/RetryClient.html +## Aborting requests + +Some clients, such as [`BrowserClient`][browserclient], [`IOClient`][ioclient], and +[`RetryClient`][retryclient], support aborting requests before they complete. + +Aborting in this way can only be performed when using [`Client.send`][clientsend] or +[`BaseRequest.send`][baserequestsend] with an [`Abortable`][abortable] request (such +as [`AbortableRequest`][abortablerequest]). + +To abort a request, complete the [`Abortable.abortTrigger`][aborttrigger] `Future`. + +If the request is aborted before the response `Future` completes, then the response +`Future` will complete with [`RequestAbortedException`][requestabortedexception]. If +the response is a `StreamedResponse` and the the request is cancelled while the +response stream is being consumed, then the response stream will contain a +[`RequestAbortedException`][requestabortedexception]. + +```dart +import 'dart:async'; + +import 'package:http/http.dart' as http; + +Future<void> main() async { + final abortTrigger = Completer<void>(); + final client = Client(); + final request = AbortableRequest( + 'GET', + Uri.https('example.com'), + abortTrigger: abortTrigger.future, + ); + + // Whenever abortion is required: + // > abortTrigger.complete(); + + // Send request + final StreamedResponse response; + try { + response = await client.send(request); + } on RequestAbortedException { + // request aborted before it was fully sent + rethrow; + } + + // Using full response bytes listener + response.stream.listen( + (data) { + // consume response bytes + }, + onError: (Object err) { + if (err is RequestAbortedException) { + // request aborted whilst response bytes are being streamed; + // the stream will always be finished early + } + }, + onDone: () { + // response bytes consumed, or partially consumed if finished + // early due to abortion + }, + ); + + // Alternatively, using `asFuture` + try { + await response.stream.listen( + (data) { + // consume response bytes + }, + ).asFuture<void>(); + } on RequestAbortedException { + // request aborted whilst response bytes are being streamed + rethrow; + } + // response bytes fully consumed +} +``` + +[browserclient]: https://pub.dev/documentation/http/latest/browser_client/BrowserClient-class.html +[ioclient]: https://pub.dev/documentation/http/latest/io_client/IOClient-class.html +[retryclient]: https://pub.dev/documentation/http/latest/retry/RetryClient-class.html +[clientsend]: https://pub.dev/documentation/http/latest/http/Client/send.html +[baserequestsend]: https://pub.dev/documentation/http/latest/http/BaseRequest/send.html +[abortable]: https://pub.dev/documentation/http/latest/http/Abortable-class.html +[abortablerequest]: https://pub.dev/documentation/http/latest/http/AbortableRequest-class.html +[aborttrigger]: https://pub.dev/documentation/http/latest/http/Abortable/abortTrigger.html +[requestabortedexception]: https://pub.dev/documentation/http/latest/http/RequestAbortedException-class.html + + ## Choosing an implementation There are multiple implementations of the `package:http` [`Client`][client] interface. By default, `package:http` uses [`BrowserClient`][browserclient] on the web and [`IOClient`][ioclient] on all other platforms. You can choose a different [`Client`][client] implementation based on the needs of your application.
diff --git a/pkgs/http/lib/http.dart b/pkgs/http/lib/http.dart index 317a2c1..31043f0 100644 --- a/pkgs/http/lib/http.dart +++ b/pkgs/http/lib/http.dart
@@ -14,6 +14,7 @@ import 'src/response.dart'; import 'src/streamed_request.dart'; +export 'src/abortable.dart'; export 'src/base_client.dart'; export 'src/base_request.dart'; export 'src/base_response.dart'
diff --git a/pkgs/http/lib/retry.dart b/pkgs/http/lib/retry.dart index dedba9a..8d8c370 100644 --- a/pkgs/http/lib/retry.dart +++ b/pkgs/http/lib/retry.dart
@@ -52,6 +52,11 @@ /// the client has a chance to perform side effects like logging. The /// `response` parameter will be null if the request was retried due to an /// error for which [whenError] returned `true`. + /// + /// If the inner client supports aborting requests, then this client will + /// forward any [RequestAbortedException]s thrown. A request will not be + /// retried if it is aborted (even if the inner client does not support + /// aborting requests). RetryClient( this._inner, { int retries = 3, @@ -108,11 +113,22 @@ Future<StreamedResponse> send(BaseRequest request) async { final splitter = StreamSplitter(request.finalize()); + var aborted = false; + if (request case Abortable(:final abortTrigger?)) { + unawaited(abortTrigger.whenComplete(() => aborted = true)); + } + var i = 0; for (;;) { StreamedResponse? response; try { + // If the inner client doesn't support abortable, we still try to avoid + // re-requests when aborted + if (aborted) throw RequestAbortedException(request.url); + response = await _inner.send(_copyRequest(request, splitter.split())); + } on RequestAbortedException { + rethrow; } catch (error, stackTrace) { if (i == _retries || !await _whenError(error, stackTrace)) rethrow; } @@ -122,7 +138,7 @@ // Make sure the response stream is listened to so that we don't leave // dangling connections. - _unawaited(response.stream.listen((_) {}).cancel().catchError((_) {})); + unawaited(response.stream.listen((_) {}).cancel().catchError((_) {})); } await Future<void>.delayed(_delay(i)); @@ -133,7 +149,18 @@ /// Returns a copy of [original] with the given [body]. StreamedRequest _copyRequest(BaseRequest original, Stream<List<int>> body) { - final request = StreamedRequest(original.method, original.url) + final StreamedRequest request; + if (original case Abortable(:final abortTrigger?)) { + request = AbortableStreamedRequest( + original.method, + original.url, + abortTrigger: abortTrigger, + ); + } else { + request = StreamedRequest(original.method, original.url); + } + + request ..contentLength = original.contentLength ..followRedirects = original.followRedirects ..headers.addAll(original.headers) @@ -158,5 +185,3 @@ Duration _defaultDelay(int retryCount) => const Duration(milliseconds: 500) * math.pow(1.5, retryCount); - -void _unawaited(Future<void>? f) {}
diff --git a/pkgs/http/lib/src/abortable.dart b/pkgs/http/lib/src/abortable.dart new file mode 100644 index 0000000..dd26a48 --- /dev/null +++ b/pkgs/http/lib/src/abortable.dart
@@ -0,0 +1,43 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'base_request.dart'; +import 'client.dart'; +import 'exception.dart'; +import 'streamed_response.dart'; + +/// An HTTP request that can be aborted before it completes. +abstract mixin class Abortable implements BaseRequest { + /// Completion of this future aborts this request (if the client supports + /// abortion). + /// + /// Requests/responses may be aborted at any time during their lifecycle. + /// + /// * If completed before the request has been finalized and sent, + /// [Client.send] completes with [RequestAbortedException]. + /// * If completed after the response headers are available, or whilst + /// streaming the response, clients inject [RequestAbortedException] into + /// the [StreamedResponse.stream] then close the stream. + /// * If completed after the response is fully complete, there is no effect. + /// + /// A common pattern is aborting a request when another event occurs (such as + /// a user action): use a [Completer] to implement this. To implement a + /// timeout (to abort the request after a set time has elapsed), use + /// [Future.delayed]. + /// + /// This future must not complete with an error. + /// + /// Some clients may not support abortion, or may not support this trigger. + abstract final Future<void>? abortTrigger; +} + +/// Thrown when an HTTP request is aborted. +/// +/// This exception is triggered when [Abortable.abortTrigger] completes. +class RequestAbortedException extends ClientException { + RequestAbortedException([Uri? uri]) + : super('Request aborted by `abortTrigger`', uri); +}
diff --git a/pkgs/http/lib/src/base_request.dart b/pkgs/http/lib/src/base_request.dart index d718e38..616eff9 100644 --- a/pkgs/http/lib/src/base_request.dart +++ b/pkgs/http/lib/src/base_request.dart
@@ -7,6 +7,7 @@ import 'package:meta/meta.dart'; import '../http.dart' show ClientException, get; +import 'abortable.dart'; import 'base_client.dart'; import 'base_response.dart'; import 'byte_stream.dart'; @@ -20,6 +21,10 @@ /// [BaseClient.send], which allows the user to provide fine-grained control /// over the request properties. However, usually it's easier to use convenience /// methods like [get] or [BaseClient.get]. +/// +/// Subclasses/implementers should mixin/implement [Abortable] to support +/// request cancellation. A future breaking version of 'package:http' will +/// merge [Abortable] into [BaseRequest], making it a requirement. abstract class BaseRequest { /// The HTTP method of the request. ///
diff --git a/pkgs/http/lib/src/browser_client.dart b/pkgs/http/lib/src/browser_client.dart index acf2334..0f57d5c 100644 --- a/pkgs/http/lib/src/browser_client.dart +++ b/pkgs/http/lib/src/browser_client.dart
@@ -8,12 +8,14 @@ import 'package:web/web.dart' show AbortController, + DOMException, HeadersInit, ReadableStreamDefaultReader, RequestInfo, RequestInit, Response; +import 'abortable.dart'; import 'base_client.dart'; import 'base_request.dart'; import 'exception.dart'; @@ -49,8 +51,6 @@ /// Responses are streamed but requests are not. A request will only be sent /// once all the data is available. class BrowserClient extends BaseClient { - final _abortController = AbortController(); - /// Whether to send credentials such as cookies or authorization headers for /// cross-site requests. /// @@ -58,6 +58,7 @@ bool withCredentials = false; bool _isClosed = false; + final _openRequestAbortControllers = <AbortController>[]; /// Sends an HTTP request and asynchronously returns the response. @override @@ -67,8 +68,17 @@ 'HTTP request failed. Client is already closed.', request.url); } + final abortController = AbortController(); + _openRequestAbortControllers.add(abortController); + final bodyBytes = await request.finalize().toBytes(); try { + if (request case Abortable(:final abortTrigger?)) { + // Tear-offs of external extension type interop members are disallowed + // ignore: unnecessary_lambdas + unawaited(abortTrigger.whenComplete(() => abortController.abort())); + } + final response = await _fetch( '${request.url}'.toJS, RequestInit( @@ -81,7 +91,7 @@ for (var header in request.headers.entries) header.key: header.value, }.jsify()! as HeadersInit, - signal: _abortController.signal, + signal: abortController.signal, redirect: request.followRedirects ? 'follow' : 'error', ), ).toDart; @@ -116,20 +126,28 @@ ); } catch (e, st) { _rethrowAsClientException(e, st, request); + } finally { + _openRequestAbortControllers.remove(abortController); } } /// Closes the client. /// - /// This terminates all active requests. + /// This terminates all active requests, which may cause them to throw + /// [RequestAbortedException] or [ClientException]. @override void close() { + for (final abortController in _openRequestAbortControllers) { + abortController.abort(); + } _isClosed = true; - _abortController.abort(); } } Never _rethrowAsClientException(Object e, StackTrace st, BaseRequest request) { + if (e case DOMException(:final name) when name == 'AbortError') { + Error.throwWithStackTrace(RequestAbortedException(request.url), st); + } if (e is! ClientException) { var message = e.toString(); if (message.startsWith('TypeError: ')) {
diff --git a/pkgs/http/lib/src/io_client.dart b/pkgs/http/lib/src/io_client.dart index fe4834b..f259181 100644 --- a/pkgs/http/lib/src/io_client.dart +++ b/pkgs/http/lib/src/io_client.dart
@@ -2,13 +2,10 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'dart:async'; import 'dart:io'; -import 'base_client.dart'; -import 'base_request.dart'; -import 'base_response.dart'; -import 'client.dart'; -import 'exception.dart'; +import '../http.dart'; import 'io_streamed_response.dart'; /// Create an [IOClient]. @@ -123,7 +120,85 @@ ioRequest.headers.set(name, value); }); - var response = await stream.pipe(ioRequest) as HttpClientResponse; + // SDK request aborting is only effective up until the request is closed, + // at which point the full response always becomes available. + // This occurs at `pipe`, which automatically closes the request once the + // request stream has been pumped in. + // + // Therefore, we have multiple strategies: + // * If the user aborts before we have a response, we can use SDK abort, + // which causes the `pipe` (and therefore this method) to throw the + // aborted error + // * If the user aborts after we have a response but before they listen + // to it, we immediately emit the aborted error then close the response + // as soon as they listen to it + // * If the user aborts whilst streaming the response, we inject the + // aborted error, then close the response + + var isAborted = false; + var hasResponse = false; + + if (request case Abortable(:final abortTrigger?)) { + unawaited( + abortTrigger.whenComplete(() { + isAborted = true; + if (!hasResponse) { + ioRequest.abort(RequestAbortedException(request.url)); + } + }), + ); + } + + final response = await stream.pipe(ioRequest) as HttpClientResponse; + hasResponse = true; + + StreamSubscription<List<int>>? ioResponseSubscription; + + late final StreamController<List<int>> responseController; + responseController = StreamController( + onListen: () { + if (isAborted) { + responseController + ..addError(RequestAbortedException(request.url)) + ..close(); + return; + } else if (request case Abortable(:final abortTrigger?)) { + abortTrigger.whenComplete(() { + if (!responseController.isClosed) { + responseController + ..addError(RequestAbortedException(request.url)) + ..close(); + } + ioResponseSubscription?.cancel(); + }); + } + + ioResponseSubscription = response.listen( + responseController.add, + onDone: () { + // `reponseController.close` will trigger the `onCancel` callback. + // Assign `ioResponseSubscription` to `null` to avoid calling its + // `cancel` method. + ioResponseSubscription = null; + unawaited(responseController.close()); + }, + onError: (Object err, StackTrace stackTrace) { + if (err is HttpException) { + responseController.addError( + ClientException(err.message, err.uri), + stackTrace, + ); + } else { + responseController.addError(err, stackTrace); + } + }, + ); + }, + onPause: () => ioResponseSubscription?.pause(), + onResume: () => ioResponseSubscription?.resume(), + onCancel: () => ioResponseSubscription?.cancel(), + sync: true, + ); var headers = <String, String>{}; response.headers.forEach((key, values) { @@ -134,22 +209,20 @@ }); return _IOStreamedResponseV2( - response.handleError((Object error) { - final httpException = error as HttpException; - throw ClientException(httpException.message, httpException.uri); - }, test: (error) => error is HttpException), - response.statusCode, - contentLength: - response.contentLength == -1 ? null : response.contentLength, - request: request, - headers: headers, - isRedirect: response.isRedirect, - url: response.redirects.isNotEmpty - ? response.redirects.last.location - : request.url, - persistentConnection: response.persistentConnection, - reasonPhrase: response.reasonPhrase, - inner: response); + responseController.stream, + response.statusCode, + contentLength: + response.contentLength == -1 ? null : response.contentLength, + request: request, + headers: headers, + isRedirect: response.isRedirect, + url: response.redirects.isNotEmpty + ? response.redirects.last.location + : request.url, + persistentConnection: response.persistentConnection, + reasonPhrase: response.reasonPhrase, + inner: response, + ); } on SocketException catch (error) { throw _ClientSocketException(error, request.url); } on HttpException catch (error) { @@ -161,6 +234,9 @@ /// /// Terminates all active connections. If a client remains unclosed, the Dart /// process may not terminate. + /// + /// The behavior of `close` is not defined if there are requests executing + /// when `close` is called. @override void close() { if (_inner != null) {
diff --git a/pkgs/http/lib/src/mock_client.dart b/pkgs/http/lib/src/mock_client.dart index 52f108a..cc02ffc 100644 --- a/pkgs/http/lib/src/mock_client.dart +++ b/pkgs/http/lib/src/mock_client.dart
@@ -4,6 +4,7 @@ import 'dart:convert'; +import 'abortable.dart'; import 'base_client.dart'; import 'base_request.dart'; import 'byte_stream.dart'; @@ -26,6 +27,10 @@ /// This client allows you to define a handler callback for all requests that /// are made through it so that you can mock a server without having to send /// real HTTP requests. +/// +/// This client does not support aborting requests directly - it is the +/// handler's responsibility to throw [RequestAbortedException] as and when +/// necessary. class MockClient extends BaseClient { /// The handler for receiving [StreamedRequest]s and sending /// [StreamedResponse]s.
diff --git a/pkgs/http/lib/src/multipart_request.dart b/pkgs/http/lib/src/multipart_request.dart index 7952542..e571574 100644 --- a/pkgs/http/lib/src/multipart_request.dart +++ b/pkgs/http/lib/src/multipart_request.dart
@@ -5,6 +5,7 @@ import 'dart:convert'; import 'dart:math'; +import 'abortable.dart'; import 'base_request.dart'; import 'boundary_characters.dart'; import 'byte_stream.dart'; @@ -160,3 +161,15 @@ return '$prefix${String.fromCharCodes(list)}'; } } + +/// A [MultipartRequest] which supports abortion using [abortTrigger]. +/// +/// A future breaking version of 'package:http' will merge this into +/// [MultipartRequest], making it a requirement. +final class AbortableMultipartRequest extends MultipartRequest with Abortable { + AbortableMultipartRequest(super.method, super.url, {this.abortTrigger}) + : super(); + + @override + final Future<void>? abortTrigger; +}
diff --git a/pkgs/http/lib/src/request.dart b/pkgs/http/lib/src/request.dart index c15e551..b7e56ab 100644 --- a/pkgs/http/lib/src/request.dart +++ b/pkgs/http/lib/src/request.dart
@@ -7,6 +7,7 @@ import 'package:http_parser/http_parser.dart'; +import 'abortable.dart'; import 'base_request.dart'; import 'byte_stream.dart'; import 'utils.dart'; @@ -182,3 +183,14 @@ throw StateError("Can't modify a finalized Request."); } } + +/// A [Request] which supports abortion using [abortTrigger]. +/// +/// A future breaking version of 'package:http' will merge this into [Request], +/// making it a requirement. +final class AbortableRequest extends Request with Abortable { + AbortableRequest(super.method, super.url, {this.abortTrigger}) : super(); + + @override + final Future<void>? abortTrigger; +}
diff --git a/pkgs/http/lib/src/streamed_request.dart b/pkgs/http/lib/src/streamed_request.dart index d10386e..8a910c5 100644 --- a/pkgs/http/lib/src/streamed_request.dart +++ b/pkgs/http/lib/src/streamed_request.dart
@@ -4,6 +4,7 @@ import 'dart:async'; +import 'abortable.dart'; import 'base_client.dart'; import 'base_request.dart'; import 'byte_stream.dart'; @@ -53,3 +54,15 @@ return ByteStream(_controller.stream); } } + +/// A [StreamedRequest] which supports abortion using [abortTrigger]. +/// +/// A future breaking version of 'package:http' will merge this into +/// [StreamedRequest], making it a requirement. +final class AbortableStreamedRequest extends StreamedRequest with Abortable { + AbortableStreamedRequest(super.method, super.url, {this.abortTrigger}) + : super(); + + @override + final Future<void>? abortTrigger; +}
diff --git a/pkgs/http/pubspec.yaml b/pkgs/http/pubspec.yaml index bd915cf..442b6b9 100644 --- a/pkgs/http/pubspec.yaml +++ b/pkgs/http/pubspec.yaml
@@ -1,5 +1,5 @@ name: http -version: 1.4.1-wip +version: 1.5.0-beta.2 description: A composable, multi-platform, Future-based API for HTTP requests. repository: https://github.com/dart-lang/http/tree/master/pkgs/http @@ -15,7 +15,7 @@ async: ^2.5.0 http_parser: ^4.0.0 meta: ^1.3.0 - web: '>=0.5.0 <2.0.0' + web: ">=0.5.0 <2.0.0" dev_dependencies: dart_flutter_team_lints: ^3.0.0
diff --git a/pkgs/http/test/html/client_conformance_test.dart b/pkgs/http/test/html/client_conformance_test.dart index 4400c6a..51a8134 100644 --- a/pkgs/http/test/html/client_conformance_test.dart +++ b/pkgs/http/test/html/client_conformance_test.dart
@@ -10,10 +10,13 @@ import 'package:test/test.dart'; void main() { - testAll(BrowserClient.new, - redirectAlwaysAllowed: true, - canStreamRequestBody: false, - canStreamResponseBody: true, - canWorkInIsolates: false, - supportsMultipartRequest: false); + testAll( + BrowserClient.new, + redirectAlwaysAllowed: true, + canStreamRequestBody: false, + canStreamResponseBody: true, + canWorkInIsolates: false, + supportsMultipartRequest: false, + supportsAbort: true, + ); }
diff --git a/pkgs/http/test/http_retry_test.dart b/pkgs/http/test/http_retry_test.dart index da51154..f29be9a 100644 --- a/pkgs/http/test/http_retry_test.dart +++ b/pkgs/http/test/http_retry_test.dart
@@ -2,6 +2,8 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'dart:async'; + import 'package:fake_async/fake_async.dart'; import 'package:http/http.dart'; import 'package:http/retry.dart'; @@ -252,4 +254,94 @@ final response = await client.get(Uri.http('example.org', '')); expect(response.statusCode, equals(200)); }); + + test('abort in first response', () async { + final client = RetryClient( + MockClient(expectAsync1((_) async => throw RequestAbortedException())), + delay: (_) => Duration.zero, + ); + + expect( + client.get(Uri.http('example.org', '')), + throwsA(isA<RequestAbortedException>()), + ); + }); + + test('abort in second response', () async { + var count = 0; + final client = RetryClient( + MockClient( + expectAsync1( + (_) async { + if (++count == 1) return Response('', 503); + throw RequestAbortedException(); + }, + count: 2, + ), + ), + delay: (_) => Duration.zero, + ); + + expect( + client.get(Uri.http('example.org', '')), + throwsA(isA<RequestAbortedException>()), + ); + }); + + test('abort in second response stream', () async { + var count = 0; + final client = RetryClient( + MockClient.streaming( + expectAsync2( + (_, __) async { + if (++count == 1) { + return StreamedResponse(const Stream.empty(), 503); + } + return StreamedResponse( + Stream.error(RequestAbortedException()), + 200, + ); + }, + count: 2, + ), + ), + delay: (_) => Duration.zero, + ); + + expect( + (await client.send(StreamedRequest('GET', Uri.http('example.org', '')))) + .stream + .single, + throwsA(isA<RequestAbortedException>()), + ); + }); + + test('abort without abortable inner client', () async { + final abortCompleter = Completer<void>(); + + var count = 0; + final client = RetryClient( + MockClient( + expectAsync1( + (_) async { + if (++count == 2) abortCompleter.complete(); + return Response('', 503); + }, + count: 2, + ), + ), + delay: (_) => Duration.zero, + ); + + expect( + client.send( + AbortableRequest( + 'GET', + Uri.http('example.org', ''), + abortTrigger: abortCompleter.future, + ), + ), + throwsA(isA<RequestAbortedException>()), + ); + }); }
diff --git a/pkgs/http/test/io/client_conformance_test.dart b/pkgs/http/test/io/client_conformance_test.dart index cc4b788..149b041 100644 --- a/pkgs/http/test/io/client_conformance_test.dart +++ b/pkgs/http/test/io/client_conformance_test.dart
@@ -16,5 +16,6 @@ canSendCookieHeaders: true, correctlyHandlesNullHeaderValues: false, // https://github.com/dart-lang/sdk/issues/56636 + supportsAbort: true, ); }
diff --git a/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart b/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart index 0686c9d..d7e21ce 100644 --- a/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart +++ b/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart
@@ -4,6 +4,7 @@ import 'package:http/http.dart'; +import 'src/abort_tests.dart'; import 'src/close_tests.dart'; import 'src/compressed_response_body_tests.dart'; import 'src/isolate_test.dart'; @@ -22,6 +23,7 @@ import 'src/response_status_line_tests.dart'; import 'src/server_errors_test.dart'; +export 'src/abort_tests.dart' show testAbort; export 'src/close_tests.dart' show testClose; export 'src/compressed_response_body_tests.dart' show testCompressedResponseBody; @@ -49,7 +51,7 @@ // /// If [canStreamResponseBody] is `false` then tests that assume that the /// [Client] supports receiving HTTP responses with unbounded body sizes will -/// be skipped +/// be skipped. /// /// If [redirectAlwaysAllowed] is `true` then tests that require the [Client] /// to limit redirects will be skipped. @@ -75,6 +77,9 @@ /// If [supportsMultipartRequest] is `false` then tests that assume that /// multipart requests can be sent will be skipped. /// +/// If [supportsAbort] is `false` then tests that assume that requests can be +/// aborted will be skipped. +/// /// The tests are run against a series of HTTP servers that are started by the /// tests. If the tests are run in the browser, then the test servers are /// started in another process. Otherwise, the test servers are run in-process. @@ -90,6 +95,7 @@ bool canSendCookieHeaders = false, bool canReceiveSetCookieHeaders = false, bool supportsMultipartRequest = true, + bool supportsAbort = false, }) { testRequestBody(clientFactory()); testRequestBodyStreamed(clientFactory(), @@ -116,4 +122,8 @@ canSendCookieHeaders: canSendCookieHeaders); testResponseCookies(clientFactory(), canReceiveSetCookieHeaders: canReceiveSetCookieHeaders); + testAbort(clientFactory(), + supportsAbort: supportsAbort, + canStreamRequestBody: canStreamRequestBody, + canStreamResponseBody: canStreamResponseBody); }
diff --git a/pkgs/http_client_conformance_tests/lib/src/abort_server.dart b/pkgs/http_client_conformance_tests/lib/src/abort_server.dart new file mode 100644 index 0000000..3075d77 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/abort_server.dart
@@ -0,0 +1,40 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an HTTP server that sends a stream of integers. +/// +/// Channel protocol: +/// On Startup: +/// - send port +/// When Receive Anything: +/// - close current request +/// - exit server +void hybridMain(StreamChannel<Object?> channel) async { + final channelQueue = StreamQueue(channel.stream); + + late HttpServer server; + server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + await request.drain<void>(); + request.response.headers.set('Access-Control-Allow-Origin', '*'); + request.response.headers.set('Content-Type', 'text/plain'); + + for (var i = 0; i < 10000; ++i) { + request.response.write('$i\n'); + await request.response.flush(); + // Let the event loop run. + await Future<void>.delayed(const Duration()); + } + await request.response.close(); + }); + + channel.sink.add(server.port); + unawaited(channelQueue.next.then((value) => unawaited(server.close()))); +}
diff --git a/pkgs/http_client_conformance_tests/lib/src/abort_server_vm.dart b/pkgs/http_client_conformance_tests/lib/src/abort_server_vm.dart new file mode 100644 index 0000000..eddcd8a --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/abort_server_vm.dart
@@ -0,0 +1,14 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; + +import 'abort_server.dart'; + +export 'server_queue_helpers.dart' show StreamQueueOfNullableObjectExtension; + +/// Starts the redirect test HTTP server in the same process. +Future<StreamChannel<Object?>> startServer() async { + final controller = StreamChannelController<Object?>(sync: true); + hybridMain(controller.foreign); + return controller.local; +}
diff --git a/pkgs/http_client_conformance_tests/lib/src/abort_server_web.dart b/pkgs/http_client_conformance_tests/lib/src/abort_server_web.dart new file mode 100644 index 0000000..3dd1104 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/abort_server_web.dart
@@ -0,0 +1,14 @@ +// Generated by generate_server_wrappers.dart. Do not edit. + +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +export 'server_queue_helpers.dart' show StreamQueueOfNullableObjectExtension; + +/// Starts the redirect test HTTP server out-of-process. +Future<StreamChannel<Object?>> startServer() async => spawnHybridUri( + Uri( + scheme: 'package', + path: 'http_client_conformance_tests/src/abort_server.dart', + ), + );
diff --git a/pkgs/http_client_conformance_tests/lib/src/abort_tests.dart b/pkgs/http_client_conformance_tests/lib/src/abort_tests.dart new file mode 100644 index 0000000..bfc691b --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/abort_tests.dart
@@ -0,0 +1,262 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:async/async.dart'; +import 'package:http/http.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +import 'abort_server_vm.dart' + if (dart.library.js_interop) 'abort_server_web.dart'; + +/// Tests that the client supports aborting requests. +/// +/// If [supportsAbort] is `false` then tests that assume that requests can be +/// aborted will be skipped. +/// +/// If [canStreamResponseBody] is `false` then tests that assume that the +/// [Client] supports receiving HTTP responses with unbounded body sizes will +/// be skipped. +/// +/// If [canStreamRequestBody] is `false` then tests that assume that the +/// [Client] supports sending HTTP requests with unbounded body sizes will be +/// skipped. +void testAbort( + Client client, { + bool supportsAbort = false, + bool canStreamRequestBody = true, + bool canStreamResponseBody = true, +}) { + group('abort', () { + late String host; + late StreamChannel<Object?> httpServerChannel; + late StreamQueue<Object?> httpServerQueue; + late Uri serverUrl; + + setUp(() async { + httpServerChannel = await startServer(); + httpServerQueue = StreamQueue(httpServerChannel.stream); + host = 'localhost:${await httpServerQueue.nextAsInt}'; + serverUrl = Uri.http(host, ''); + }); + tearDownAll(() => httpServerChannel.sink.add(null)); + + test('before request', () async { + final abortTrigger = Completer<void>(); + final request = AbortableRequest( + 'GET', + serverUrl, + abortTrigger: abortTrigger.future, + ); + abortTrigger.complete(); + + expect( + client.send(request), + throwsA(isA<RequestAbortedException>()), + ); + }); + + test('before first streamed item', () async { + final abortTrigger = Completer<void>(); + + final request = AbortableStreamedRequest( + 'POST', + serverUrl, + abortTrigger: abortTrigger.future, + ); + + final response = client.send(request); + + abortTrigger.complete(); + + expect( + response, + throwsA(isA<RequestAbortedException>()), + ); + + // Ensure that `request.sink` is still writeable after the request is + // aborted. + for (var i = 0; i < 1000; ++i) { + request.sink.add('Hello World'.codeUnits); + await Future<void>.delayed(const Duration()); + } + await request.sink.close(); + }, + skip: supportsAbort + ? (canStreamRequestBody ? false : 'does not stream response bodies') + : 'does not support aborting requests'); + + test('during request stream', () async { + final abortTrigger = Completer<void>(); + + final request = AbortableStreamedRequest( + 'POST', + serverUrl, + abortTrigger: abortTrigger.future, + ); + + final response = client.send(request); + request.sink.add('Hello World'.codeUnits); + + abortTrigger.complete(); + + expect( + response, + throwsA(isA<RequestAbortedException>()), + ); + + // Ensure that `request.sink` is still writeable after the request is + // aborted. + for (var i = 0; i < 1000; ++i) { + request.sink.add('Hello World'.codeUnits); + await Future<void>.delayed(const Duration()); + } + await request.sink.close(); + }, + skip: supportsAbort + ? (canStreamRequestBody ? false : 'does not stream request bodies') + : 'does not support aborting requests'); + + test('after response, response stream listener', () async { + final abortTrigger = Completer<void>(); + + final request = AbortableRequest( + 'GET', + serverUrl, + abortTrigger: abortTrigger.future, + ); + final response = await client.send(request); + + abortTrigger.complete(); + + expect( + response.stream.single, + throwsA(isA<RequestAbortedException>()), + ); + }); + + test('after response, response stream no listener', () async { + final abortTrigger = Completer<void>(); + + final request = AbortableRequest( + 'GET', + serverUrl, + abortTrigger: abortTrigger.future, + ); + final response = await client.send(request); + + abortTrigger.complete(); + // Ensure that the abort has time to run before listening to the response + // stream + await Future<void>.delayed(const Duration()); + + expect( + response.stream.single, + throwsA(isA<RequestAbortedException>()), + ); + }); + + test('after response, response stream paused', () async { + final abortTrigger = Completer<void>(); + + final request = AbortableRequest( + 'GET', + serverUrl, + abortTrigger: abortTrigger.future, + ); + final response = await client.send(request); + + final subscription = response.stream.listen(print)..pause(); + abortTrigger.complete(); + // Ensure that the abort has time to run before listening to the response + // stream + await Future<void>.delayed(const Duration()); + subscription.resume(); + + expect( + subscription.asFuture<void>(), + throwsA(isA<RequestAbortedException>()), + ); + }); + + test( + 'while streaming response', + () async { + final abortTrigger = Completer<void>(); + + final request = AbortableRequest( + 'GET', + serverUrl, + abortTrigger: abortTrigger.future, + ); + final response = await client.send(request); + + // Verify that fewer than the 10000 lines sent by the server are + // received. + var i = 0; + await expectLater( + response.stream + .transform(const Utf8Decoder()) + .transform(const LineSplitter()) + .listen( + (_) { + if (++i >= 1000 && !abortTrigger.isCompleted) { + abortTrigger.complete(); + } + }, + ).asFuture<void>(), + throwsA(isA<RequestAbortedException>()), + ); + expect(i, lessThan(10000)); + }, + skip: supportsAbort + ? (canStreamResponseBody ? false : 'does not stream response bodies') + : 'does not support aborting requests', + ); + + test('after streaming response', () async { + final abortTrigger = Completer<void>(); + + final request = AbortableRequest( + 'GET', + serverUrl, + abortTrigger: abortTrigger.future, + ); + + final response = await client.send(request); + await response.stream.drain<void>(); + + abortTrigger.complete(); + }); + + test('after response, client still useable', () async { + final abortTrigger = Completer<void>(); + + final request = AbortableRequest( + 'GET', + serverUrl, + abortTrigger: abortTrigger.future, + ); + + final abortResponse = await client.send(request); + + abortTrigger.complete(); + + var requestAbortCaught = false; + try { + await abortResponse.stream.drain<void>(); + } on RequestAbortedException { + requestAbortCaught = true; + } + + final response = await client.get(serverUrl); + expect(response.statusCode, 200); + expect(response.body, endsWith('9999\n')); + expect(requestAbortCaught, true); + }); + }, skip: supportsAbort ? false : 'does not support aborting requests'); +}
diff --git a/pkgs/http_client_conformance_tests/pubspec.yaml b/pkgs/http_client_conformance_tests/pubspec.yaml index 2d8d652..60f8a2f 100644 --- a/pkgs/http_client_conformance_tests/pubspec.yaml +++ b/pkgs/http_client_conformance_tests/pubspec.yaml
@@ -3,7 +3,6 @@ A library that tests whether implementations of package:http's `Client` class behave as expected. repository: https://github.com/dart-lang/http/tree/master/pkgs/http_client_conformance_tests - publish_to: none environment: @@ -11,10 +10,15 @@ dependencies: async: ^2.8.2 - dart_style: '>=2.3.7 <4.0.0' + dart_style: ">=2.3.7 <4.0.0" http: ^1.2.0 stream_channel: ^2.1.1 test: ^1.21.2 +# TODO(brianquinlan): Remove dependency_overrides when package:http 1.5.0 is released. +dependency_overrides: + http: + path: ../http + dev_dependencies: dart_flutter_team_lints: ^3.0.0
diff --git a/pkgs/ok_http/example/pubspec.yaml b/pkgs/ok_http/example/pubspec.yaml index 235b649..873d9aa 100644 --- a/pkgs/ok_http/example/pubspec.yaml +++ b/pkgs/ok_http/example/pubspec.yaml
@@ -35,3 +35,9 @@ uses-material-design: true assets: - test_certs/ # Used in integration tests. + +# TODO(brianquinlan): Remove this when a release version of `package:http` +# supports abortable requests. +dependency_overrides: + http: + path: ../../http/