Three hooks shape how the generated client sends requests: interceptors (rewrite or answer requests),
timeout and retry (both on RevaliClient). Timeouts and retries are off by default.
Interceptors#
An HttpInterceptor sees every request before it is sent and every response after it arrives:
abstract interface class HttpInterceptor {
FutureOr<HttpResponse?> onRequest(HttpRequest request);
FutureOr<HttpResponse?> onResponse(HttpResponse response);
}
Mutate the HttpRequest and return null to carry on. That covers most interceptors, such as auth headers:
import 'package:revali_client/revali_client.dart';
class AuthInterceptor implements HttpInterceptor {
const AuthInterceptor(this.storage);
final Storage storage;
@override
Future<HttpResponse?> onRequest(HttpRequest request) async {
if (await storage['auth_token'] case final String token) {
request.headers['Authorization'] = 'Bearer $token';
}
return null;
}
@override
HttpResponse? onResponse(HttpResponse response) => null;
}
Register interceptors on the HttpClient you pass to Server:
final storage = SessionStorage();
final server = Server(
storage: storage,
client: HttpPackageClient(
interceptors: [AuthInterceptor(storage), const LoggingInterceptor()],
),
);
// or, after construction:
server.client.interceptors.add(const LoggingInterceptor());
Rules#
- Order. Interceptors run in list order, for requests and for responses alike. Put interceptors that change the request before ones that only log it.
-
Replacing a response. Return an
HttpResponsefromonResponseto substitute it. The next interceptor sees your replacement. -
Answering without the network. Return an
HttpResponsefromonRequestand nothing is sent: later interceptors and the wholeonResponsechain are skipped. This is how you build an offline cache or a stub. -
Errors propagate. An interceptor that throws fails the request, and the error reaches your call site. If the interceptor's work is optional, catch inside it and return
null. -
The body is built last. The outgoing request is assembled after every
onRequesthas run, so changes tobody,bodyBytes,encodingorcontentLengthare sent, not just header changes. - Once per attempt. With retries on, interceptors run again for each attempt.
HttpRequest has method, url, mutable headers, body,
bodyBytes, bodyStream, encoding and contentLength.
HttpResponse has request, statusCode, headers, reasonPhrase,
contentLength, persistentConnection and the body stream.
HeaderInterceptor#
For headers that must be computed per request, such as forwarding trace headers from a server to a peer, use the built-in
HeaderInterceptor. Its callback runs once per request, and it never overwrites a header the call already set:
final client = Server(
client: HttpPackageClient(
interceptors: [
HeaderInterceptor(
() => TraceContext.current?.outboundHeaders() ?? const {},
),
],
),
);
See Tracing for the server side.
Timeouts#
RevaliClient.timeout bounds how long a request waits for the status line and headers. It does not bound streaming the body afterwards, so long downloads are not cut off. Exceeding it throws a
TimeoutException. It is null (wait forever) by default.
The timeout applies to each attempt. With maxAttempts: 3 and a 10 second timeout, an unresponsive peer can hold a call for about 30 seconds plus backoff.
To bound the body download too, add your own .timeout() where you read response.stream.
Retries#
RevaliClient.retry is RetryPolicy.none() by default. const RetryPolicy()
turns on a safe default:
-
Only idempotent methods:
GET,HEAD,OPTIONS,PUT,DELETE(case-insensitive).POSTandPATCHare never retried, because aPOSTthat reached the server and then lost its response would create the resource twice. -
Only transient statuses:
502,503,504. A400,404or500would fail the same way again. - Transport failures (refused, reset, timed out) are retried too, still only for idempotent methods.
-
Streamed bodies are never retried. A
bodyStreamis consumed by the first attempt. Send aList<int>orStringif the request must be retryable. -
Backoff doubles from
initialDelayup tomaxDelay: 200 ms, 400 ms, 800 ms and so on, capped at 10 s. -
Retry-Afterin delta-seconds form (Retry-After: 7) replaces the backoff and is not capped bymaxDelay. The HTTP-date form is ignored. SethonorRetryAfter: falseto always use the backoff. - A response that is discarded for a retry is drained first, so retries do not leak connections.
RetryPolicy option | Default | Meaning |
|---|---|---|
maxAttempts |
3 |
Total attempts including the first. Must be >= 1. |
initialDelay |
200ms |
Delay before the second attempt. Doubles each time. |
maxDelay | 10s | Cap on the computed backoff. |
retryableStatusCodes |
{502, 503, 504} |
Statuses to retry. |
idempotentMethods |
{GET, HEAD, OPTIONS, PUT, DELETE} |
Methods that may be retried. Add
POST
only for endpoints that are safe to repeat, such as ones that take an idempotency key.
|
retryOnConnectionErrors |
true |
Retry transport failures. |
honorRetryAfter |
true |
Let a Retry-After delay override the backoff. |
Timeout and retry wrap the transport inside RevaliClient, so a custom HttpClient
gets both without extra work.
Configuring a generated client#
The generated Server builds its own RevaliClient and accepts only client,
storage and baseUrl (plus websocket). It does not forward timeout
or retry. To use them, build a RevaliClient and pass it to the generated implementations directly:
import 'package:client/client.dart';
import 'package:revali_client/revali_client.dart';
final storage = SessionStorage();
final client = RevaliClient(
storage: storage,
baseUrl: 'https://api.example.com/api',
client: HttpPackageClient(interceptors: [AuthInterceptor(storage)]),
timeout: const Duration(seconds: 10),
retry: const RetryPolicy(),
);
final users = UserDataSourceImpl(client: client, storage: storage);
Migrating to 3.0.0#
revali_client 3.0.0 made two breaking changes to HttpInterceptor:
-
onRequestandonResponsereturnFutureOr<HttpResponse?>instead ofFutureOr<void>. Change the return type and addreturn null;. The analyzer flags every interceptor that still uses the old signature. -
An interceptor that throws now fails the request instead of being swallowed. Wrap optional work, such as analytics, in
try/catchinside the interceptor.
// Before
@override
FutureOr<void> onRequest(HttpRequest request) {
print('-> ${request.method} ${request.url}');
}
// After
@override
FutureOr<HttpResponse?> onRequest(HttpRequest request) {
print('-> ${request.method} ${request.url}');
return null;
}