fix(lifecycle): abort HTTP before close
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:plezy/utils/abortable_http_request.dart';
|
||||
import 'package:plezy/utils/managed_http_client.dart';
|
||||
|
||||
void main() {
|
||||
group('ManagedHttpClient', () {
|
||||
test('preserves headers added by request finalization', () async {
|
||||
final inner = _CapturingClient();
|
||||
final client = ManagedHttpClient(inner, debugLabel: 'test');
|
||||
addTearDown(() => client.closeGracefully(drainTimeout: Duration.zero));
|
||||
|
||||
final request = http.MultipartRequest('POST', Uri.parse('https://example.test/upload'))
|
||||
..fields['title'] = 'Movie';
|
||||
final response = await client.send(request);
|
||||
await response.stream.drain<void>();
|
||||
|
||||
expect(inner.headers?['content-type'], startsWith('multipart/form-data; boundary='));
|
||||
expect(inner.body, isNotEmpty);
|
||||
});
|
||||
|
||||
test('closeGracefully aborts and cancels an active streamed response before closing inner client', () async {
|
||||
final inner = _StreamingClient();
|
||||
final client = ManagedHttpClient(inner, debugLabel: 'test');
|
||||
addTearDown(() async {
|
||||
await client.closeGracefully(drainTimeout: Duration.zero);
|
||||
await inner.dispose();
|
||||
});
|
||||
|
||||
final response = await client.send(http.Request('GET', Uri.parse('https://example.test/slow')));
|
||||
|
||||
await client.closeGracefully(drainTimeout: const Duration(milliseconds: 100));
|
||||
|
||||
expect(inner.closeCount, 1);
|
||||
expect(inner.responseCancelled, isTrue);
|
||||
await expectLater(inner.abortTrigger, completes);
|
||||
await expectLater(response.stream.toList(), completion(isEmpty));
|
||||
});
|
||||
|
||||
test('closeGracefully can retry after a drain timeout', () async {
|
||||
final inner = _DeferredSendClient();
|
||||
final client = ManagedHttpClient(inner, debugLabel: 'test');
|
||||
addTearDown(() => client.closeGracefully(drainTimeout: Duration.zero));
|
||||
|
||||
final responseFuture = client.send(http.Request('GET', Uri.parse('https://example.test/slow')));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await client.closeGracefully(drainTimeout: const Duration(milliseconds: 1));
|
||||
expect(inner.closeCount, 0);
|
||||
|
||||
var retryCompleted = false;
|
||||
final retryClose = client.closeGracefully(drainTimeout: const Duration(seconds: 1));
|
||||
unawaited(retryClose.whenComplete(() => retryCompleted = true));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(retryCompleted, isFalse);
|
||||
|
||||
inner.completeWithEmptyResponse();
|
||||
final response = await responseFuture;
|
||||
await response.stream.drain<void>();
|
||||
await retryClose;
|
||||
|
||||
expect(inner.closeCount, 1);
|
||||
});
|
||||
|
||||
test('preserves final response URL metadata', () async {
|
||||
final finalUrl = Uri.parse('https://example.test/final');
|
||||
final inner = _UrlResponseClient(finalUrl);
|
||||
final client = ManagedHttpClient(inner, debugLabel: 'test');
|
||||
addTearDown(() => client.closeGracefully(drainTimeout: Duration.zero));
|
||||
|
||||
final response = await client.send(http.Request('GET', Uri.parse('https://example.test/start')));
|
||||
|
||||
expect(response, isA<http.BaseResponseWithUrl>().having((r) => r.url, 'url', finalUrl));
|
||||
await response.stream.drain<void>();
|
||||
});
|
||||
});
|
||||
|
||||
group('sendAbortableHttpRequest', () {
|
||||
test('aborts the underlying request when its timeout fires', () async {
|
||||
final inner = _HangingClient();
|
||||
addTearDown(inner.close);
|
||||
|
||||
await expectLater(
|
||||
sendAbortableHttpRequest(
|
||||
inner,
|
||||
'GET',
|
||||
Uri.parse('https://example.test/slow'),
|
||||
timeout: const Duration(milliseconds: 1),
|
||||
operation: 'slow request',
|
||||
),
|
||||
throwsA(isA<TimeoutException>().having((e) => e.message, 'message', 'slow request timed out')),
|
||||
);
|
||||
|
||||
await expectLater(inner.abortTrigger, completes);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _CapturingClient extends http.BaseClient {
|
||||
Map<String, String>? headers;
|
||||
List<int>? body;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
headers = Map.of(request.headers);
|
||||
body = await request.finalize().toBytes();
|
||||
return http.StreamedResponse(const Stream<List<int>>.empty(), 200, request: request);
|
||||
}
|
||||
}
|
||||
|
||||
class _StreamingClient extends http.BaseClient {
|
||||
_StreamingClient() {
|
||||
responseController = StreamController<List<int>>(
|
||||
onCancel: () {
|
||||
responseCancelled = true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final StreamController<List<int>> responseController;
|
||||
Future<void>? abortTrigger;
|
||||
var closeCount = 0;
|
||||
var responseCancelled = false;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
abortTrigger = (request as http.Abortable).abortTrigger;
|
||||
return http.StreamedResponse(responseController.stream, 200, request: request);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
closeCount += 1;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await responseController.close();
|
||||
}
|
||||
}
|
||||
|
||||
class _DeferredSendClient extends http.BaseClient {
|
||||
final _response = Completer<http.StreamedResponse>();
|
||||
http.BaseRequest? request;
|
||||
Future<void>? abortTrigger;
|
||||
var closeCount = 0;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
this.request = request;
|
||||
abortTrigger = (request as http.Abortable).abortTrigger;
|
||||
return _response.future;
|
||||
}
|
||||
|
||||
void completeWithEmptyResponse() {
|
||||
_response.complete(http.StreamedResponse(const Stream<List<int>>.empty(), 200, request: request));
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
closeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class _UrlResponseClient extends http.BaseClient {
|
||||
_UrlResponseClient(this.url);
|
||||
|
||||
final Uri url;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
return _ResponseWithUrl(const Stream<List<int>>.empty(), 200, url: url, request: request);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResponseWithUrl extends http.StreamedResponse implements http.BaseResponseWithUrl {
|
||||
_ResponseWithUrl(super.stream, super.statusCode, {required this.url, super.request});
|
||||
|
||||
@override
|
||||
final Uri url;
|
||||
}
|
||||
|
||||
class _HangingClient extends http.BaseClient {
|
||||
final _response = Completer<http.StreamedResponse>();
|
||||
Future<void>? abortTrigger;
|
||||
var closed = false;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
abortTrigger = (request as http.Abortable).abortTrigger;
|
||||
return _response.future;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user