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/