Skip to content

Commit ecbada1

Browse files
fix: debugging and fixing three issues 0528 (#163)
* Fix retry logic, URL parameter security, and timeout handling Co-authored-by: me <[email protected]> * fix: improve retry policies, url parsing and documentation * fix: remove unneeded markdown files * style: dart formatting * fix: handle missing edge cases on retry request fn --------- Co-authored-by: Cursor Agent <[email protected]>
1 parent 2e15171 commit ecbada1

File tree

5 files changed

+264
-25
lines changed

5 files changed

+264
-25
lines changed

README.md

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,11 +227,25 @@ Sometimes you need to retry a request due to different circumstances, an expired
227227

228228
```dart
229229
class ExpiredTokenRetryPolicy extends RetryPolicy {
230+
@override
231+
int get maxRetryAttempts => 2;
232+
233+
@override
234+
bool shouldAttemptRetryOnException(Exception reason, BaseRequest request) {
235+
// Log the exception for debugging
236+
print('Request failed: ${reason.toString()}');
237+
print('Request URL: ${request.url}');
238+
239+
// Retry on network exceptions, but not on client errors
240+
return reason is SocketException || reason is TimeoutException;
241+
}
242+
230243
@override
231244
Future<bool> shouldAttemptRetryOnResponse(BaseResponse response) async {
232245
if (response.statusCode == 401) {
233246
// Perform your token refresh here.
234-
247+
print('Token expired, refreshing...');
248+
235249
return true;
236250
}
237251
@@ -242,13 +256,56 @@ class ExpiredTokenRetryPolicy extends RetryPolicy {
242256

243257
You can also set the maximum amount of retry attempts with `maxRetryAttempts` property or override the `shouldAttemptRetryOnException` if you want to retry the request after it failed with an exception.
244258

259+
### RetryPolicy Interface
260+
261+
The `RetryPolicy` abstract class provides the following methods that you can override:
262+
263+
- **`shouldAttemptRetryOnException(Exception reason, BaseRequest request)`**: Called when an exception occurs during the request. Return `true` to retry, `false` to fail immediately.
264+
- **`shouldAttemptRetryOnResponse(BaseResponse response)`**: Called after receiving a response. Return `true` to retry, `false` to accept the response.
265+
- **`maxRetryAttempts`**: The maximum number of retry attempts (default: 1).
266+
- **`delayRetryAttemptOnException({required int retryAttempt})`**: Delay before retrying after an exception (default: no delay).
267+
- **`delayRetryAttemptOnResponse({required int retryAttempt})`**: Delay before retrying after a response (default: no delay).
268+
269+
### Using Retry Policies
270+
271+
To use a retry policy, pass it to the `InterceptedClient` or `InterceptedHttp`:
272+
273+
```dart
274+
final client = InterceptedClient.build(
275+
interceptors: [WeatherApiInterceptor()],
276+
retryPolicy: ExpiredTokenRetryPolicy(),
277+
);
278+
```
279+
245280
Sometimes it is helpful to have a cool-down phase between multiple requests. This delay could for example also differ between the first and the second retry attempt as shown in the following example.
246281

247282
```dart
248283
class ExpiredTokenRetryPolicy extends RetryPolicy {
284+
@override
285+
int get maxRetryAttempts => 3;
286+
287+
@override
288+
bool shouldAttemptRetryOnException(Exception reason, BaseRequest request) {
289+
// Only retry on network-related exceptions
290+
return reason is SocketException || reason is TimeoutException;
291+
}
292+
293+
@override
294+
Future<bool> shouldAttemptRetryOnResponse(BaseResponse response) async {
295+
// Retry on server errors (5xx) and authentication errors (401)
296+
return response.statusCode >= 500 || response.statusCode == 401;
297+
}
298+
299+
@override
300+
Duration delayRetryAttemptOnException({required int retryAttempt}) {
301+
// Exponential backoff for exceptions
302+
return Duration(milliseconds: (250 * math.pow(2.0, retryAttempt - 1)).round());
303+
}
304+
249305
@override
250306
Duration delayRetryAttemptOnResponse({required int retryAttempt}) {
251-
return const Duration(milliseconds: 250) * math.pow(2.0, retryAttempt);
307+
// Exponential backoff for response-based retries
308+
return Duration(milliseconds: (250 * math.pow(2.0, retryAttempt - 1)).round());
252309
}
253310
}
254311
```

lib/http/intercepted_client.dart

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -274,16 +274,89 @@ class InterceptedClient extends BaseClient {
274274
/// of the response
275275
Future<BaseResponse> _attemptRequest(BaseRequest request,
276276
{bool isStream = false}) async {
277+
_retryCount = 0; // Reset retry count for each new request
278+
return _attemptRequestWithRetries(request, isStream: isStream);
279+
}
280+
281+
/// Internal method that handles the actual request with retry logic
282+
Future<BaseResponse> _attemptRequestWithRetries(BaseRequest request,
283+
{bool isStream = false}) async {
277284
BaseResponse response;
278285
try {
279286
// Intercept request
280287
final interceptedRequest = await _interceptRequest(request);
281288

282-
var stream = requestTimeout == null
283-
? await _inner.send(interceptedRequest)
284-
: await _inner
285-
.send(interceptedRequest)
286-
.timeout(requestTimeout!, onTimeout: onRequestTimeout);
289+
StreamedResponse stream;
290+
if (requestTimeout == null) {
291+
stream = await _inner.send(interceptedRequest);
292+
} else {
293+
// Use a completer to properly handle timeout and cancellation
294+
final completer = Completer<StreamedResponse>();
295+
final Future<StreamedResponse> requestFuture =
296+
_inner.send(interceptedRequest);
297+
298+
// Set up timeout with proper cleanup
299+
Timer? timeoutTimer;
300+
bool isCompleted = false;
301+
302+
timeoutTimer = Timer(requestTimeout!, () {
303+
if (!isCompleted) {
304+
isCompleted = true;
305+
if (onRequestTimeout != null) {
306+
// If timeout callback is provided, use it
307+
try {
308+
final timeoutResponse = onRequestTimeout!();
309+
if (timeoutResponse is Future<StreamedResponse>) {
310+
timeoutResponse.then((response) {
311+
if (!completer.isCompleted) {
312+
completer.complete(response);
313+
}
314+
}).catchError((error) {
315+
if (!completer.isCompleted) {
316+
completer.completeError(error);
317+
}
318+
});
319+
} else {
320+
if (!completer.isCompleted) {
321+
completer.complete(timeoutResponse);
322+
}
323+
}
324+
} catch (error) {
325+
if (!completer.isCompleted) {
326+
completer.completeError(error);
327+
}
328+
}
329+
} else {
330+
// Default timeout behavior
331+
if (!completer.isCompleted) {
332+
completer.completeError(Exception(
333+
'Request timeout after ${requestTimeout!.inMilliseconds}ms'));
334+
}
335+
}
336+
}
337+
});
338+
339+
// Handle the actual request completion
340+
requestFuture.then((streamResponse) {
341+
timeoutTimer?.cancel();
342+
if (!isCompleted) {
343+
isCompleted = true;
344+
if (!completer.isCompleted) {
345+
completer.complete(streamResponse);
346+
}
347+
}
348+
}).catchError((error) {
349+
timeoutTimer?.cancel();
350+
if (!isCompleted) {
351+
isCompleted = true;
352+
if (!completer.isCompleted) {
353+
completer.completeError(error);
354+
}
355+
}
356+
});
357+
358+
stream = await completer.future;
359+
}
287360

288361
response = isStream ? stream : await Response.fromStream(stream);
289362

@@ -293,7 +366,7 @@ class InterceptedClient extends BaseClient {
293366
_retryCount += 1;
294367
await Future.delayed(retryPolicy!
295368
.delayRetryAttemptOnResponse(retryAttempt: _retryCount));
296-
return _attemptRequest(request, isStream: isStream);
369+
return _attemptRequestWithRetries(request, isStream: isStream);
297370
}
298371
} on Exception catch (error) {
299372
if (retryPolicy != null &&
@@ -302,13 +375,12 @@ class InterceptedClient extends BaseClient {
302375
_retryCount += 1;
303376
await Future.delayed(retryPolicy!
304377
.delayRetryAttemptOnException(retryAttempt: _retryCount));
305-
return _attemptRequest(request, isStream: isStream);
378+
return _attemptRequestWithRetries(request, isStream: isStream);
306379
} else {
307380
rethrow;
308381
}
309382
}
310383

311-
_retryCount = 0;
312384
return response;
313385
}
314386

lib/models/retry_policy.dart

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import 'package:http/http.dart';
1212
/// int get maxRetryAttempts => 2;
1313
///
1414
/// @override
15-
/// bool shouldAttemptRetryOnException(Exception reason) {
15+
/// bool shouldAttemptRetryOnException(Exception reason, BaseRequest request) {
1616
/// log(reason.toString());
17+
/// log("Request URL: ${request.url}");
1718
///
1819
/// return false;
1920
/// }
@@ -36,20 +37,45 @@ import 'package:http/http.dart';
3637
abstract class RetryPolicy {
3738
/// Defines whether the request should be retried when an Exception occurs
3839
/// while making said request to the server.
40+
///
41+
/// [reason] - The exception that occurred during the request
42+
/// [request] - The original request that failed
43+
///
44+
/// Returns `true` if the request should be retried, `false` otherwise.
3945
FutureOr<bool> shouldAttemptRetryOnException(
4046
Exception reason, BaseRequest request) =>
4147
false;
4248

4349
/// Defines whether the request should be retried after the request has
4450
/// received `response` from the server.
51+
///
52+
/// [response] - The response received from the server
53+
///
54+
/// Returns `true` if the request should be retried, `false` otherwise.
55+
/// Common use cases include retrying on 401 (Unauthorized) or 500 (Server Error).
4556
FutureOr<bool> shouldAttemptRetryOnResponse(BaseResponse response) => false;
4657

4758
/// Number of maximum request attempts that can be retried.
59+
///
60+
/// Default is 1, meaning the original request plus 1 retry attempt.
61+
/// Set to 0 to disable retries, or higher values for more retry attempts.
4862
int get maxRetryAttempts => 1;
4963

64+
/// Delay before retrying when an exception occurs.
65+
///
66+
/// [retryAttempt] - The current retry attempt number (1-based)
67+
///
68+
/// Returns the delay duration. Default is no delay (Duration.zero).
69+
/// Consider implementing exponential backoff for production use.
5070
Duration delayRetryAttemptOnException({required int retryAttempt}) =>
5171
Duration.zero;
5272

73+
/// Delay before retrying when a response indicates retry is needed.
74+
///
75+
/// [retryAttempt] - The current retry attempt number (1-based)
76+
///
77+
/// Returns the delay duration. Default is no delay (Duration.zero).
78+
/// Consider implementing exponential backoff for production use.
5379
Duration delayRetryAttemptOnResponse({required int retryAttempt}) =>
5480
Duration.zero;
5581
}

lib/utils/query_parameters.dart

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,63 @@
11
/// Takes a string and appends [parameters] as query parameters of [url].
22
///
3-
/// It does not check if [url] is valid, it just appends the parameters.
3+
/// It validates the URL structure and properly encodes both keys and values
4+
/// to prevent URL injection attacks.
45
String buildUrlString(String url, Map<String, dynamic>? parameters) {
56
// Avoids unnecessary processing.
67
if (parameters == null) return url;
78

89
// Check if there are parameters to add.
910
if (parameters.isNotEmpty) {
11+
// Validate URL structure to prevent injection
12+
// First check if it looks like a valid HTTP/HTTPS URL
13+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
14+
throw ArgumentError(
15+
'Invalid URL structure: $url - must be a valid HTTP/HTTPS URL',
16+
);
17+
}
18+
19+
try {
20+
final uri = Uri.parse(url);
21+
// Additional validation: ensure it has a host
22+
if (uri.host.isEmpty) {
23+
throw ArgumentError(
24+
'Invalid URL structure: $url - must have a valid host',
25+
);
26+
}
27+
} catch (e) {
28+
if (e is ArgumentError) {
29+
rethrow;
30+
}
31+
throw ArgumentError('Invalid URL structure: $url');
32+
}
33+
1034
// Checks if the string url already has parameters.
1135
if (url.contains("?")) {
1236
url += "&";
1337
} else {
1438
url += "?";
1539
}
1640

17-
// Concat every parameter to the string url.
41+
// Concat every parameter to the string url with proper encoding
1842
parameters.forEach((key, value) {
43+
// Encode the key to prevent injection
44+
final encodedKey = Uri.encodeQueryComponent(key);
45+
1946
if (value is List) {
2047
if (value is List<String>) {
2148
for (String singleValue in value) {
22-
url += "$key=${Uri.encodeQueryComponent(singleValue)}&";
49+
url += "$encodedKey=${Uri.encodeQueryComponent(singleValue)}&";
2350
}
2451
} else {
2552
for (dynamic singleValue in value) {
26-
url += "$key=${Uri.encodeQueryComponent(singleValue.toString())}&";
53+
url +=
54+
"$encodedKey=${Uri.encodeQueryComponent(singleValue.toString())}&";
2755
}
2856
}
2957
} else if (value is String) {
30-
url += "$key=${Uri.encodeQueryComponent(value)}&";
58+
url += "$encodedKey=${Uri.encodeQueryComponent(value)}&";
3159
} else {
32-
url += "$key=${Uri.encodeQueryComponent(value.toString())}&";
60+
url += "$encodedKey=${Uri.encodeQueryComponent(value.toString())}&";
3361
}
3462
});
3563

0 commit comments

Comments
 (0)