Skip to content

Commit c53d0b3

Browse files
authored
feat: Add timeout handling for ConversionLayerAdapter (#2456)
fixes #2457 ## Description Implements timeout handling for `NativeAdapter`. Previously, Dio's timeout options (`sendTimeout`, `connectTimeout`, `receiveTimeout`) were ignored when using `NativeAdapter`. This PR adds proper timeout features in `ConversionLayerAdapter`, following the same approach as `io_adapter.dart`. **Note:** Due to http package limitations, `connectTimeout` and `receiveTimeout` are combined for the response phase.
1 parent 79972cd commit c53d0b3

File tree

4 files changed

+169
-7
lines changed

4 files changed

+169
-7
lines changed

plugins/native_dio_adapter/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Support request cancellation for native HTTP clients via use of `AbortableRequest` (introduced in http package from version 1.5.0)
6+
- Add timeout handling for `sendTimeout`, `connectTimeout`, and `receiveTimeout` in `ConversionLayerAdapter`
67

78
## 1.5.0
89

plugins/native_dio_adapter/lib/src/conversion_layer_adapter.dart

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,57 @@ class ConversionLayerAdapter implements HttpClientAdapter {
2121
Future<ResponseBody> fetch(
2222
RequestOptions options,
2323
Stream<Uint8List>? requestStream,
24-
Future<dynamic>? cancelFuture,
24+
Future<void>? cancelFuture,
2525
) async {
26-
final request = await _fromOptionsAndStream(
26+
final timeoutCompleter = Completer<void>();
27+
28+
final cancelToken = cancelFuture != null
29+
? Future.any([cancelFuture, timeoutCompleter.future])
30+
: timeoutCompleter.future;
31+
final requestFuture = _fromOptionsAndStream(
2732
options,
2833
requestStream,
29-
cancelFuture,
34+
cancelToken,
3035
);
31-
final response = await client.send(request);
36+
37+
final sendTimeout = options.sendTimeout ?? Duration.zero;
38+
final BaseRequest request;
39+
if (sendTimeout == Duration.zero) {
40+
request = await requestFuture;
41+
} else {
42+
request = await requestFuture.timeout(
43+
sendTimeout,
44+
onTimeout: () {
45+
timeoutCompleter.complete();
46+
throw DioException.sendTimeout(
47+
timeout: sendTimeout,
48+
requestOptions: options,
49+
);
50+
},
51+
);
52+
}
53+
54+
// http package doesn't separate connect and receive phases,
55+
// so we combine both timeouts for client.send()
56+
final connectTimeout = options.connectTimeout ?? Duration.zero;
57+
final receiveTimeout = options.receiveTimeout ?? Duration.zero;
58+
final totalTimeout = connectTimeout + receiveTimeout;
59+
final StreamedResponse response;
60+
if (totalTimeout == Duration.zero) {
61+
response = await client.send(request);
62+
} else {
63+
response = await client.send(request).timeout(
64+
totalTimeout,
65+
onTimeout: () {
66+
timeoutCompleter.complete();
67+
throw DioException.receiveTimeout(
68+
timeout: totalTimeout,
69+
requestOptions: options,
70+
);
71+
},
72+
);
73+
}
74+
3275
return response.toDioResponseBody(options);
3376
}
3477

@@ -38,7 +81,7 @@ class ConversionLayerAdapter implements HttpClientAdapter {
3881
Future<BaseRequest> _fromOptionsAndStream(
3982
RequestOptions options,
4083
Stream<Uint8List>? requestStream,
41-
Future<dynamic>? cancelFuture,
84+
Future<void> cancelFuture,
4285
) async {
4386
final request = AbortableRequest(
4487
options.method,

plugins/native_dio_adapter/test/client_mock.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,26 @@ class AbortClientMock implements Client {
6666
}
6767

6868
class AbortedError extends Error {}
69+
70+
class DelayedClientMock implements Client {
71+
DelayedClientMock({
72+
required this.duration,
73+
});
74+
75+
final Duration duration;
76+
77+
@override
78+
Future<StreamedResponse> send(BaseRequest request) async {
79+
await Future<void>.delayed(duration);
80+
81+
return StreamedResponse(
82+
Stream.fromIterable([]),
83+
200,
84+
);
85+
}
86+
87+
@override
88+
void noSuchMethod(Invocation invocation) {
89+
throw UnimplementedError();
90+
}
91+
}

plugins/native_dio_adapter/test/conversion_layer_adapter_test.dart

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ void main() {
6363
expect(await resp.stream.length, 5);
6464
});
6565

66-
test('request cancellation', () {
66+
test('request cancellation', () async {
6767
final mock = AbortClientMock();
6868
final cla = ConversionLayerAdapter(mock);
6969
final cancelToken = CancelToken();
@@ -74,7 +74,7 @@ void main() {
7474
},
7575
);
7676

77-
expectLater(
77+
await expectLater(
7878
() => cla.fetch(
7979
RequestOptions(path: ''),
8080
null,
@@ -110,4 +110,99 @@ void main() {
110110
);
111111
expect(mock.isRequestCanceled, true);
112112
});
113+
114+
group('Timeout tests', () {
115+
test('sendTimeout throws DioException.sendTimeout', () async {
116+
final mock = ClientMock()
117+
..response = StreamedResponse(const Stream.empty(), 200);
118+
final cla = ConversionLayerAdapter(mock);
119+
120+
final delayedStream = Stream<Uint8List>.periodic(
121+
const Duration(milliseconds: 10),
122+
(count) => Uint8List.fromList([count]),
123+
);
124+
125+
try {
126+
await cla.fetch(
127+
RequestOptions(
128+
path: '',
129+
sendTimeout: const Duration(milliseconds: 1),
130+
),
131+
delayedStream,
132+
null,
133+
);
134+
fail('Should have thrown DioException');
135+
} on DioException catch (e) {
136+
expect(e.type, DioExceptionType.sendTimeout);
137+
expect(e.message, contains('1'));
138+
}
139+
});
140+
141+
test('receiveTimeout throws DioException.receiveTimeout', () async {
142+
final mock = DelayedClientMock(
143+
duration: const Duration(milliseconds: 10),
144+
);
145+
final cla = ConversionLayerAdapter(mock);
146+
147+
try {
148+
await cla.fetch(
149+
RequestOptions(
150+
path: '',
151+
receiveTimeout: const Duration(milliseconds: 1),
152+
),
153+
null,
154+
null,
155+
);
156+
fail('Should have thrown DioException');
157+
} on DioException catch (e) {
158+
expect(e.type, DioExceptionType.receiveTimeout);
159+
expect(e.message, contains('1'));
160+
}
161+
});
162+
163+
test('connectTimeout and receiveTimeout are combined', () async {
164+
final mock = DelayedClientMock(
165+
duration: const Duration(milliseconds: 10),
166+
);
167+
final cla = ConversionLayerAdapter(mock);
168+
169+
try {
170+
await cla.fetch(
171+
RequestOptions(
172+
path: '',
173+
connectTimeout: const Duration(milliseconds: 1),
174+
receiveTimeout: const Duration(milliseconds: 1),
175+
),
176+
null,
177+
null,
178+
);
179+
fail('Should have thrown DioException');
180+
} on DioException catch (e) {
181+
expect(e.type, DioExceptionType.receiveTimeout);
182+
expect(e.message, contains('2'));
183+
}
184+
});
185+
186+
test('AbortableRequest is triggered on receiveTimeout', () async {
187+
final mock = AbortClientMock();
188+
final cla = ConversionLayerAdapter(mock);
189+
190+
try {
191+
await cla.fetch(
192+
RequestOptions(
193+
path: '',
194+
receiveTimeout: const Duration(milliseconds: 1),
195+
),
196+
null,
197+
null,
198+
);
199+
fail('Should have thrown DioException');
200+
} on DioException catch (e) {
201+
expect(e.type, DioExceptionType.receiveTimeout);
202+
// Give delay for the abortTrigger callback to execute
203+
await Future<void>.delayed(Duration.zero);
204+
expect(mock.isRequestCanceled, isTrue);
205+
}
206+
});
207+
});
113208
}

0 commit comments

Comments
 (0)