Transport
Every request — batched queries, mutations and subscription connections — is built by
the client, which takes care of credentials (auth), retries
(retry) and time limits (timeout), and sends
each attempt through a single function:
typedef Transport = Future<http.Response> Function(http.Request request);By default the client sends the request with its http.Client. Pass your own
transport to log, use another HTTP stack or a test double. The request you receive is a
finalized POST (an http.AbortableRequest) with content-type: application/json, the
client’s static headers and the auth headers already applied.
final client = SlingClient<Query>( endpoint: Uri.parse('https://api.example.com/graphql'), schema: slingSchema, headers: const {'x-app-version': '1.2.0'}, // static, on every request auth: SlingAuth( headers: () async => {'authorization': 'Bearer ${await tokens.access()}'}, refresh: tokens.refresh, ), timeout: const Duration(seconds: 15), retry: const RetryPolicy(maxAttempts: 4),);SlingClient(auth:) adds credentials to every request and refreshes them when the
server rejects them:
auth: SlingAuth( headers: () async => {'authorization': 'Bearer ${await tokens.access()}'}, refresh: () async { await tokens.refresh(); // throw when the session is over for good }, // default: HTTP 401, or a GraphQL error with extensions.code UNAUTHENTICATED isUnauthenticated: SlingAuth.isUnauthenticatedError,),headersruns before each attempt (retries and replays included), so a request always carries the current token.- On an unauthenticated response the client calls
refreshand replays the request once with fresh headers. Refreshing is single-flight: requests failing together share onerefreshcall, and a request whose 401 arrives after another one already refreshed just replays with the new token. - When
refresh(orheaders) throws, or the replay is rejected again, the request fails with aSlingAuthException(cause: the refresh error or the last response’s error) — the moment to sign the user out:
if (state.error is SlingAuthException) return const SignInScreen();- Subscriptions get the same headers on connect. A 401 (or an
UNAUTHENTICATEDevent) refreshes and reopens the connection once; if that fails, theSlingAuthExceptionis a stream error and the subscription follows its usualretryAfterpath.
Queries are retried with exponential backoff and jitter by default:
SlingClient(retry: RetryPolicy()) makes 3 attempts in all, waiting 300 ms, then
600 ms (each minus up to 50 % jitter, capped at maxDelay), for
network errors, timeouts and 5xx (RetryPolicy.isTransient).
SlingClient<Query>( ..., retry: RetryPolicy( maxAttempts: 5, initialDelay: const Duration(milliseconds: 200), maxDelay: const Duration(seconds: 5), retryIf: (e) => RetryPolicy.isTransient(e) || e.statusCode == 429, ),);// or: retry: RetryPolicy.none — report the first failure- Mutations are never retried by default: one that timed out or lost its connection
may have been applied. Opt in per call for the ones that are safe to repeat:
client.mutate(..., retry: const RetryPolicy()). - A partial response (data and errors) is never retried; neither are 4xx and GraphQL
errors unless your
retryIfsays so. - Subscriptions do not use it: a dropped connection reopens after
subscriptionRetryAfter(see Subscriptions). SlingRequest.attempts(and the request overlay’s log line) shows how many tries a request took.
Timeout and cancellation
Section titled “Timeout and cancellation”SlingClient(timeout:) limits each attempt; the request is aborted and fails with a
SlingTimeoutException (which retry treats as transient). It defaults to null, no
limit. Per widget or call: QueryBuilder(timeout:), useSlingQuery(timeout:),
createScope, resolve, client.mutate, MutationBuilder’s mutate. A batch shared by
several widgets waits for the longest of their timeouts (none, if one has none).
Subscriptions have no timeout — they stay open.
Requests are cancelled when nobody waits for them any more: when every widget whose
selections are in a query batch is disposed (a screen popped mid-request), the request is
aborted and its response, if one still comes, is dropped; nothing is sent at all when they
are gone before the batch is flushed. A disposed scope is never notified. Aborting needs a
client that supports http.Abortable (IOClient, BrowserClient); others just have
their response ignored.
Recipes
Section titled “Recipes”Logging
Section titled “Logging”onOperation already reports every printed document, and client.requests /
logRequests every request with its timing; use the transport when you want the raw
response too:
final _http = http.Client();
transport: (request) async { final sw = Stopwatch()..start(); final res = await http.Response.fromStream(await _http.send(request)); log('${res.statusCode} ${sw.elapsedMilliseconds}ms ${request.body.length}B'); return res;},Pass the request itself to _http.send: it carries the abort trigger.
Offline detection
Section titled “Offline detection”SlingException.isNetworkUnreachable (exactly SlingNetworkException) tells “the device
could not reach the server” from every answer the server gave:
if (error?.isNetworkUnreachable ?? false) showOfflineBanner();What the client does with the response
Section titled “What the client does with the response”Each attempt’s outcome is classified (see the error model):
- The transport throws:
http.ClientException→SlingNetworkException, aTimeoutException→SlingTimeoutException, anything else →SlingTransportException(SlingException.from). statusCode >= 400→SlingHttpException(status, body, the body’s GraphQL errors) for the whole batch.- A body that is not JSON →
SlingTransportException. - A JSON body without
data→SlingGraphQLExceptionwith the errors. errorsalongsidedata→ a partialSlingGraphQLException; what happens to the errored paths is the scopes’errorPolicy(see Loading states & errors).
gql_link
Section titled “gql_link”Already have a gql_link chain — an auth
link, a retry link, persisted queries, a websocket link? sling_gql_link
turns it into the client’s transport (and subscriptionTransport). The
runtime stays http-only; gql, gql_exec, gql_link and gql_http_link
are that package’s dependencies.
flutter pub add sling_gql_link gql_link gql_http_linkimport 'package:gql_http_link/gql_http_link.dart';import 'package:gql_link/gql_link.dart';import 'package:sling_gql_link/sling_gql_link.dart';
final authLink = Link.function( (request, [forward]) => forward!( request.updateContextEntry<HttpLinkHeaders>( (headers) => HttpLinkHeaders( headers: {...?headers?.headers, 'authorization': 'Bearer ${auth.current}'}, ), ), ),);
final link = Link.from([authLink, retryLink, HttpLink(endpoint)]);
final client = SlingClient<Query>( endpoint: Uri.parse(endpoint), // nominal: the terminating link sends schema: slingSchema, headers: const {'x-app-version': '1.2.0'}, // arrive as HttpLinkHeaders transport: linkTransport(link),);Going in, each batched document is parsed into the Request’s
DocumentNode (so links that hash or rewrite the document — persisted
queries — work on it), with its variables; sling_gql’s documents are
anonymous, so operationName is null. The client’s headers are in the
context as HttpLinkHeaders; content-type and accept are left to the
terminating link.
Coming back, the first Response of the link’s stream is the result:
data, errors (message, locations, path, extensions) and extensions are
handled exactly as over HTTP — partial errors pruned before caching and
reported on the scope (or kept, per errorPolicy), data: null a
SlingGraphQLException. A LinkException becomes the SlingException it stands for
(slingExceptionFromLink), so the client’s retry and auth treat a link like the default
transport:
| Link exception | Becomes |
|---|---|
HttpLinkServerException (status >= 300) |
SlingHttpException: status, body, the body’s errors |
HttpLinkParserException with status >= 300 (a proxy’s 502 page, an empty 401) |
SlingHttpException: status, body |
ServerException of a failed connection |
SlingNetworkException |
ServerException with errors and no data |
SlingGraphQLException |
any other LinkException |
SlingTransportException (cause: the link exception) |
Anything else a link throws — a TimeoutException of your own — is classified like the
default transport’s. Prefer the client’s own auth, retry and timeout to an auth or
retry link: they cover subscriptions too, and the auth refresh is single-flight.
Subscriptions over a link
Section titled “Subscriptions over a link”linkSubscriptionTransport(link) maps the link’s stream one to one: each
Response is an event, the stream completing completes the subscription, a
stream error is a transport failure (which reconnects with retryAfter, see
Subscriptions), and cancelling the
subscription cancels the link’s stream. HttpLink cannot run subscriptions;
route them to a websocket link:
final link = Link.split( (request) => request.operation.getOperationType() == OperationType.subscription, websocketLink, Link.from([authLink, HttpLink(endpoint)]),);
SlingClient<Query>( endpoint: Uri.parse(endpoint), schema: slingSchema, transport: linkTransport(link), subscriptionTransport: linkSubscriptionTransport(link),);Or keep sling_gql’s default GraphQL-over-SSE subscriptions and pass only
transport:.