-
Notifications
You must be signed in to change notification settings - Fork 382
feat: support aborting HTTP requests #1773
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 13 commits
e1a6c30
b17468d
12ae9df
5c5f59c
e7487c3
b4ab341
88b9692
28e05aa
8563f19
bdac03d
fad438d
55fb040
1c195fb
d19e9e3
05f8ae1
7ee00e6
3914e2b
1088ec8
917d573
6b5d2e2
bfe0031
e707d2c
e02a40c
a57318a
1cc77b0
9001a1d
24025ba
3d5caa7
9da76aa
0b32a5a
b9e8543
d302777
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -133,7 +133,18 @@ final class RetryClient extends BaseClient { | |
|
||
/// 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?)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't we want to avoid retries if the request is aborted? Maybe have special handling for Are there tests that need to be updated? |
||
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) | ||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,43 @@ | ||||||
// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file | ||||||
JaffaKetchup marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
// 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 'streamed_response.dart'; | ||||||
|
||||||
/// Enables a request to be recognised by a [Client] as abortable | ||||||
JaffaKetchup marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
abstract mixin class Abortable implements BaseRequest { | ||||||
/// Completion of this future aborts this request (if the client supports | ||||||
/// abortion) | ||||||
JaffaKetchup marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
/// | ||||||
/// 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 an [AbortedRequest] exception | ||||||
/// * If completed after the response headers are available, or whilst | ||||||
/// streaming response bytes, clients inject [AbortedRequest] into the | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
/// [StreamedResponse.stream] then finish it early | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
/// * If completed after the response is fully complete, there is no effect | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
JaffaKetchup marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
/// | ||||||
/// 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 to an error. | ||||||
JaffaKetchup marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
/// | ||||||
/// Some clients may not support abortion, or may not support this trigger. | ||||||
abstract final Future<void>? abortTrigger; | ||||||
} | ||||||
|
||||||
/// Thrown when an HTTP request is aborted | ||||||
JaffaKetchup marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
/// | ||||||
/// Usually, this is due to [Abortable.abortTrigger]. See documentation on that | ||||||
JaffaKetchup marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
/// property for more info. | ||||||
class AbortedRequest implements Exception { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that this should What do you think? |
||||||
/// Indicator that the request has been aborted | ||||||
const AbortedRequest(); | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,12 +8,14 @@ import 'dart:js_interop'; | |
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,15 +51,14 @@ external JSPromise<Response> _fetch( | |
/// 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. | ||
/// | ||
/// Defaults to `false`. | ||
bool withCredentials = false; | ||
|
||
bool _isClosed = false; | ||
final _openRequestAbortControllers = <AbortController>[]; | ||
|
||
/// Sends an HTTP request and asynchronously returns the response. | ||
@override | ||
|
@@ -67,8 +68,16 @@ class BrowserClient extends BaseClient { | |
'HTTP request failed. Client is already closed.', request.url); | ||
} | ||
|
||
final _abortController = AbortController(); | ||
_openRequestAbortControllers.add(_abortController); | ||
|
||
final bodyBytes = await request.finalize().toBytes(); | ||
try { | ||
Future<void>? canceller; | ||
if (request case Abortable(:final abortTrigger?)) { | ||
canceller = abortTrigger.whenComplete(() => _abortController.abort()); | ||
} | ||
|
||
final response = await _fetch( | ||
'${request.url}'.toJS, | ||
RequestInit( | ||
|
@@ -86,6 +95,8 @@ class BrowserClient extends BaseClient { | |
), | ||
).toDart; | ||
|
||
canceller?.ignore(); | ||
|
||
final contentLengthHeader = response.headers.get('content-length'); | ||
|
||
final contentLength = contentLengthHeader != null | ||
|
@@ -114,18 +125,29 @@ class BrowserClient extends BaseClient { | |
url: Uri.parse(response.url), | ||
reasonPhrase: response.statusText, | ||
); | ||
} on DOMException catch (e, st) { | ||
if (e.name == 'AbortError') { | ||
Error.throwWithStackTrace(const AbortedRequest(), st); | ||
} | ||
|
||
_rethrowAsClientException(e, st, request); | ||
} 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 | ||
/// [AbortedRequest] or [ClientException]. | ||
@override | ||
void close() { | ||
for (final abortController in _openRequestAbortControllers) { | ||
abortController.abort(); | ||
} | ||
_isClosed = true; | ||
_abortController.abort(); | ||
} | ||
} | ||
|
||
|
@@ -158,6 +180,14 @@ Stream<List<int>> _readBody(BaseRequest request, Response response) async* { | |
} | ||
yield (chunk.value! as JSUint8Array).toDart; | ||
} | ||
} on DOMException catch (e, st) { | ||
isError = true; | ||
|
||
if (e.name == 'AbortError') { | ||
Error.throwWithStackTrace(const AbortedRequest(), st); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If |
||
} | ||
|
||
_rethrowAsClientException(e, st, request); | ||
} catch (e, st) { | ||
isError = true; | ||
_rethrowAsClientException(e, st, request); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,8 +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 'abortable.dart'; | ||
import 'base_client.dart'; | ||
import 'base_request.dart'; | ||
import 'base_response.dart'; | ||
|
@@ -123,7 +125,45 @@ class IOClient extends BaseClient { | |
ioRequest.headers.set(name, value); | ||
}); | ||
|
||
var response = await stream.pipe(ioRequest) as HttpClientResponse; | ||
// We can only abort the actual connection up until the point we obtain | ||
// the response. | ||
// After that point, the full response bytes are always available. | ||
// However, we instead inject an error into the response stream to match | ||
// the behaviour of `BrowserClient`. | ||
|
||
StreamSubscription<List<int>>? subscription; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe |
||
final controller = StreamController<List<int>>(sync: true); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe |
||
|
||
if (request case Abortable(:final abortTrigger?)) { | ||
abortTrigger.whenComplete(() async { | ||
if (subscription == null) { | ||
ioRequest.abort(const AbortedRequest()); | ||
} else { | ||
if (!controller.isClosed) { | ||
controller.addError(const AbortedRequest()); | ||
} | ||
await subscription.cancel(); | ||
} | ||
await controller.close(); | ||
}); | ||
} | ||
|
||
final response = await stream.pipe(ioRequest) as HttpClientResponse; | ||
|
||
subscription = response.listen( | ||
controller.add, | ||
onDone: controller.close, | ||
onError: (Object err, StackTrace stackTrace) { | ||
if (err is HttpException) { | ||
controller.addError( | ||
ClientException(err.message, err.uri), | ||
stackTrace, | ||
); | ||
} else { | ||
controller.addError(err, stackTrace); | ||
} | ||
}, | ||
); | ||
|
||
var headers = <String, String>{}; | ||
response.headers.forEach((key, values) { | ||
|
@@ -134,22 +174,20 @@ class IOClient extends BaseClient { | |
}); | ||
|
||
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); | ||
controller.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) { | ||
|
@@ -159,8 +197,9 @@ class IOClient extends BaseClient { | |
|
||
/// Closes the client. | ||
/// | ||
/// Terminates all active connections. If a client remains unclosed, the Dart | ||
/// process may not terminate. | ||
/// Terminates all active connections, which may cause them to throw | ||
JaffaKetchup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/// [AbortedRequest] or [ClientException]/[SocketException]. If a client | ||
/// remains unclosed, the Dart process may not terminate. | ||
JaffaKetchup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
@override | ||
void close() { | ||
if (_inner != null) { | ||
|
Uh oh!
There was an error while loading. Please reload this page.