HTTP transport
Since 0.21.0. package:fespalier_transport is the one seam fespalier's networking packages share: an HttpTransport interface that sends a request and answers a response. It is pure Dart with no dependencies, so a package that sends through it links neither package:http nor Dio, and an app picks one of them (or a client of its own) once, by choosing which package implements the interface. The session of fespalier_auth, the downloads, the Tolgee CDN, the map packs, the images and the CrateStack client all send through it, and so do the telemetry decorators.
flowchart TB
subgraph users[Everything that sends]
auth["fespalier_auth<br/>SessionTransport"]
oidc["fespalier_oidc<br/>token endpoint calls"]
dl["fespalier_download<br/>HttpDownloadBackend"]
tolgee["fespalier_tolgee<br/>TolgeeCdn, TolgeeEditor"]
maps["fespalier_maps<br/>packTransport"]
image["fespalier_image<br/>ImageCdn, TransportImage"]
cs["fespalier_cratestack<br/>PortalTransport"]
tel["fespalier_otel, fespalier_sentry<br/>OtelTransport, SentryTransport"]
end
iface[["HttpTransport<br/>fespalier_transport"]]
http["fespalier_http<br/>HttpClientTransport over http.Client"]
dio["fespalier_dio<br/>DioTransport over Dio"]
own["a client of your own"]
users --> iface
iface --> http
iface --> dio
iface --> ownInstall it by installing a package that depends on it: fespalier_http, fespalier_dio and fespalier_auth bring it at the same tag. Add it yourself (same url, same ref as fespalier) to write a transport or a decorator of your own; its README has the block.
The interface
abstract interface class HttpTransport {
Future<TransportResponse> send(TransportRequest request);
}send answers once the headers are in; the body streams. Every status is an answer, not an error: a 404 and a 500 come back as a TransportResponse. It throws a TransportFailure when no response came back (see Failures), and any other error a decorator throws for its own reasons (a session's AuthRejected).
| Type | What it is |
|---|---|
TransportRequest | method, uri, headers, the body as the exact bytes on the wire (body) or a stream (bodyStream, with contentLength), an abortTrigger and attributes. replayable is false for a stream; copyWith replaces the rest |
TransportResponse | statusCode, headers (lower-case names), reasonPhrase and the body: body streams once, bytes() reads it all, TransportResponse.buffered(response) reads it and hands the response on whole |
abortTrigger | A Future<void> that completes to abort the send (package:http's abortTrigger, Dio's CancelToken). A transport honours it with no timer |
attributes | For decorators, never sent: fespalier.auth.replay: true marks a send that followed a verdict, fespalier.memo carries the memo, and an app can add its own (a key-role hint) |
Three more pieces of the package are not transports but belong to them:
HttpWritesis the one rule for what counts as a write: any method butGET,HEAD,OPTIONSandTRACE, unless the request carries anIdempotency-Keyheader.WriteGuardTransportandfespalier_dio'sWriteGuardshare it, so the two never disagree.HttpCredentialsis what a transfer that is not a transport (a download, a plugin, a WebSocket handshake on a device; in a browser a socket takes a ticket fetched through the transport) needs from a session:covers(uri),authorize(request, previous:)for the headers andretry(attempt, response)for "send it once more?".fespalier_auth'sAuthorizerimplements it (Credentials for clients that are not an http.Client).TransportZonecarries an abort trigger in aZone, for code that cannot hand one to the request it makes: a generated client takes no abort trigger, soref.cancellableinfespalier_cratestackruns the call in a zone that carries one, andTransportAdaptermerges it into the request'sabortTrigger.
The two implementations
| Package | Sends with | The other way round |
|---|---|---|
fespalier_http | HttpClientTransport(client), over an http.Client (IOClient, cupertino_http) | TransportClient(transport): an http.Client over any chain, for package:http code |
fespalier_dio | DioTransport(dio), over a Dio and its interceptors | TransportAdapter(transport): a Dio HttpClientAdapter over any chain |
Each pair is a bridge in both directions, so an app has one chain and both worlds on top of it:
final chain = SessionTransport(WriteGuardTransport(HttpClientTransport(http.Client())), authorizer);
final client = TransportClient(chain); // package:http code, RetryClient
final dio = Dio()..httpClientAdapter = TransportAdapter(chain); // a Dio with its interceptors and dio_smart_retry
final response = await chain.send(TransportRequest(method: 'GET', uri: Uri.parse('https://api.example.com/me')));What each one does with a request and an error (a DioException or an http.ClientException becomes a TransportFailure, and back) is in HTTP clients; fespalier_dio's README has the table of DioExceptionType to failure. Dio's DioHttpClient (your Dio as an http.Client) is gone since 0.21.0: send through DioTransport, or through TransportClient(DioTransport(dio)) when code needs an http.Client.
Writing your own
A transport over a client of your own (a platform channel, a gRPC-web gateway, a cronet client) maps three things: the request to the client's, the response back to a TransportResponse, and the client's errors to a TransportFailure.
final class MyClientTransport implements HttpTransport {
const MyClientTransport(this.client);
final MyClient client;
@override
Future<TransportResponse> send(TransportRequest request) async {
try {
final raw = await client.send(
request.method,
request.uri,
headers: request.headers,
body: request.body ?? request.bodyStream, // the exact bytes, or a stream sent once
abort: request.abortTrigger,
);
return TransportResponse(
statusCode: raw.status,
headers: {for (final h in raw.headers.entries) h.key.toLowerCase(): h.value},
body: raw.body,
);
} on MyAbort catch (e) {
throw TransportAborted(e, request.uri);
} on MyRefused catch (e) {
throw TransportUnsent(e, request.uri); // it never left: sending it again is always safe
} on MyTimeout catch (e) {
throw TransportTimedOut(e, request.uri);
} on Object catch (e) {
throw TransportBroken(e, request.uri);
}
}
}A decorator is a transport over a transport, and that is all it needs to be:
final class TimingTransport implements HttpTransport {
const TimingTransport(this.inner, this.report);
final HttpTransport inner;
final void Function(String method, String path, int? status, Duration took) report;
@override
Future<TransportResponse> send(TransportRequest request) async {
final watch = Stopwatch()..start();
try {
final response = await inner.send(request); // the request as it came: attributes and all
report(request.method, request.uri.path, response.statusCode, watch.elapsed);
return response;
} on TransportFailure {
report(request.method, request.uri.path, null, watch.elapsed);
rethrow;
}
}
}The rules a decorator keeps, because the others rely on them:
- Pass
attributesthrough unchanged. They carry the auth replay mark and the memo; a decorator that builds a newTransportRequestwithout them breaks the session above it. Userequest.copyWith(headers: ...), which keeps them, and add to the map rather than replace it. - Pass the
abortTriggerand the body through. A stream body is sent once; do not read it to look at it. - Do not consume a body you hand on. To look at a response body, use
TransportResponse.buffered(response)and return the buffered response. - Throw a
TransportFailurefor a transport problem, and let any other error through. A session'sAuthRejectedorAuthUnavailablecomes out of a decorator below it as it is. - No timer, no listener, and nothing sensitive in what you record: never a header value, a body or a query.
- Test it with
FakeTransportfrompackage:fespalier_transport/testing.dart: it answers from a handler or a script (FakeTransport.script([...]), aTransportResponseor aTransportFailureper request), honours an abort trigger, and records every request with its body.
Decorators and their order
| Decorator | Package | What it does |
|---|---|---|
SessionTransport(inner, authorizer) | fespalier_auth | Attaches the session to requests to apiOrigins, renews once, sends again after a verdict (Calling your API) |
WriteGuardTransport(inner) | fespalier_transport | Marks each response and error as a write's or a read's, so a retrier leaves writes alone (Writes are never retried) |
PortalTransport(inner) | fespalier_cratestack | A text/html answer becomes a TransportPortal (a captive portal or a gateway page) |
OtelTransport(inner, {isReady, propagateTo}) | fespalier_otel | A CLIENT span for each request sent, a child of the current data or action span (Observability) |
SentryTransport(inner, {hub, tracing, captureFailedRequests, tracePropagationTargets}) | fespalier_sentry | A breadcrumb for each request, a span with tracing, an error event for a failed one only with captureFailedRequests |
The order, outermost first, and why (PortalTransport is only for a CrateStack client):
flowchart LR
caller["data.dart, action.dart<br/>TransportClient or TransportAdapter"] --> retrier["an app's own retrier<br/>RetryClient, dio_smart_retry"]
retrier --> session[SessionTransport]
session --> otel[OtelTransport]
otel --> sentry[SentryTransport]
sentry --> portal["PortalTransport<br/>CrateStack only"]
portal --> guard[WriteGuardTransport]
guard --> net["HttpClientTransport<br/>DioTransport"]// the same chain, built once (startup.dart, below, builds it for real)
final network = WriteGuardTransport(HttpClientTransport(http.Client()));
final observed = OtelTransport(SentryTransport(network), propagateTo: [api]);
// AuthConfig(transport: observed): authTransport adds SessionTransport(observed, authorizer) on top- A retrier is above the session, never under it: under a session that signs requests it would send the same signature again. Above it, every retry calls the session again, and the session signs the exact bytes of that send (The wrapping order).
- The telemetry decorators are below the session, so a replay after a 401 reaches them: each send is a span of its own (
http.request.resend_counton the second), where above the session the replay would be hidden inside one. PortalTransportis below the session, so a captive portal's401page is aTransportPortal(offline), not a verdict that renews or ends the session.WriteGuardTransportis next to the network, so the response a retrier sees is the one the guard marked, and the session's own replay after a 401 passes it with itsfespalier.auth.replaymark. The guard only marks, it refuses nothing (Dio'sWriteGuardinterceptor is what refuses a second send of a write); the two can be stacked in either order.- Do not also put
SentryHttpClient,sentry_dio,otel_httporotel_dioon the same requests: each request would be reported twice.
The memo
A retrier above the transport sends the same logical request again, and a decorator sees each send as a new request. TransportMemo is how it knows it has seen this one before: a small mutable bag, carried in TransportRequest.attributes['fespalier.memo'] (TransportMemo.key) and never sent. request.memo reads it, and get, set, putIfAbsent and remove use it, under a key the decorator owns (a private const string is enough; a decorator never reads another's).
SessionTransport keeps the user a request was authorized for, the AuthRejected that stopped it and the renewals it has spent in the memo, so a retry is refused when the session it wanted is gone, and a request renews at most once however many times it is sent.
A client that sends the same logical request more than once must give every send the same memo:
| Client | Where the memo lives |
|---|---|
TransportAdapter (Dio) | RequestOptions.extra, which a Dio retrier reuses: every retry of one request shares it |
TransportClient (package:http) | One per http.BaseRequest object. A RetryClient sends a copy of the request each time, so each copy is a new logical request there |
| A request with no memo | A request seen once |
A decorator must pass TransportRequest.attributes through: dropping the memo makes every send a first one, and the session above forgets what it settled.
Failures
TransportFailure is sealed: switch on it. It means no response came back, and the kind says what that tells you about the request, which is what a retrier needs.
| Kind | What happened | Was it processed? | Sending it again |
|---|---|---|---|
TransportAborted | The request's abortTrigger fired before an answer | Unknown | It was stopped on purpose |
TransportUnsent | It provably never left: connection refused, host not found, not connected in time | No | Always safe; the network is down |
TransportUntrusted | The server's TLS certificate was refused (since 0.21.0) | No | The network is not what failed: not offline |
TransportTimedOut | Sent, and no answer came in time | Maybe | Only a read |
TransportBroken | Anything else that stopped the exchange: a dropped connection, a malformed answer | Maybe | Only a read |
TransportPortal | Something other than the API answered: a captive portal's or a gateway's page (PortalTransport) | Maybe | Only a read; read as offline |
- A refused certificate is not offline. A certificate that does not verify may be a captive portal or an attack, so a client reads it as unknown: it serves no cached read for it and keeps no queued intent waiting for the network.
DioTransportthrows it for Dio'sbadCertificate,HttpClientTransportfor aHandshakeException, aTlsExceptionor aCertificateException(not on the web, where the browser tells a refused certificate from nothing else). A download whose grantor's certificate is refused endsFailed(network). - The text never names the server: a failure's
summaryis one line, and thecauseis the transport's own error, for a log. TransportStatusErroris not a failure: a response did come back, with an error status. A client that wraps answers in exceptions (Dio does for a 4xx or a 5xx) carries one throughDioErrors.toTransport, so a reader that knows no Dio type can classify it. The Tolgee CDN and editor throw one for a refusal (it was anhttp.ClientExceptionuntil 0.20.0). ItstoStringshows the status only, because the body may hold personal data.CredentialsUnavailableis what a credential throws when it could not be reached (offline while a session renews, a 5xx from the renewal endpoint): nothing was refused, so a transfer reports a network failure, not a lost session.AuthUnavailableimplements it.- CrateStack reads all of these in one place:
TransportFailures.readturnsTransportUnsent,TransportTimedOutandTransportPortalintoCrateStackOffline,TransportAbortedintoCrateStackCancelled, and aTransportStatusErrorby its status and body; aTransportBrokenand aTransportUntrustedare unknown (null), not offline (CrateStack).
Streaming bodies
send answers at the headers and the body streams, so a response that never ends (server-sent events, since 0.25.0 with fespalier_stream) goes through the chain like any other. The decorators keep it that way: SessionTransport reads only the body of a 401 or a 403 (up to 64 KiB) and hands every other response on unread, and OtelTransport and SentryTransport end their span or breadcrumb at the headers, so their duration is the time to the first byte. Two things to know about a body that streams:
- An error in the middle of a body is the client's own, not a
TransportFailure:package:httpends it with aClientException(aRequestAbortedExceptionafter an abort), Dio with aDioException.fespalier_streamreads such an error as a dropped connection, aTransportBroken, unless it aborted the request itself. - Cancelling a body does not free every connection: Dio keeps reading a body whose listener left, on the VM and on the web. Complete the request's
abortTriggerto close it, asfespalier_streamdoes whenever it gives a stream up. On the web, Dio's default adapter receives a body whole, at its end;HttpClientTransport(BrowserClient())reads it as it arrives.
One transport, built in startup
Build the chain once, in startup(), and hand the same object to everything that sends: the session, the images, the downloads, the translations, the map packs.
// lib/app/startup.dart
import 'dart:async';
import 'package:fespalier/startup.dart';
import 'package:fespalier_auth/fespalier_auth.dart';
import 'package:fespalier_http/fespalier_http.dart';
import 'package:fespalier_maps/fespalier_maps.dart';
import 'package:fespalier_otel/fespalier_otel.dart';
import 'package:fespalier_sentry/fespalier_sentry.dart';
import 'package:fespalier_transport/fespalier_transport.dart';
import 'package:http/http.dart' as http;
final api = Uri.parse('https://api.example.com');
FutureOr<List<Override>> startup() async {
// The network end of the chain: the write guard next to it, telemetry above it (Otel outside Sentry).
final transport = OtelTransport(
SentryTransport(WriteGuardTransport(HttpClientTransport(http.Client()))),
propagateTo: [api],
);
final auth = await restoreAuth(
AuthConfig(
backend: authBackend(transport), // OidcBackend(transport: transport, ...) or a DeviceAuthBackend
transport: transport, // authTransport = SessionTransport(transport, authorizer)
apiOrigins: [api],
),
);
return [
...auth,
packTransport.overrideWithValue(transport), // fespalier_maps; a file pack is a public file
];
}- Everything that is not your API takes the transport without the session: the OIDC token endpoint, the Tolgee CDN, map packs and public images are not
apiOrigins, andSessionTransportsends them through untouched anyway. Pass the sametransportand only the requests toapiOriginscarry the session. - The consumers:
HttpDownloadBackend(transport: ...)(Downloads),ImageCdn(transport: ...)(Images; notconstany more),TolgeeCdn(base, transport: ...)and thetolgeeEditorTransportprovider (Translations),packTransport(Maps), and a CrateStack client throughTransportAdapteron its Dio (CrateStack). - Tests override the same seams with a
FakeTransport:fakeAuth(transport: FakeTransport(...)),packTransport.overrideWithValue(fake),ImageCdn(transport: fake).