Skip to content

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,
),
  • headers runs before each attempt (retries and replays included), so a request always carries the current token.
  • On an unauthenticated response the client calls refresh and replays the request once with fresh headers. Refreshing is single-flight: requests failing together share one refresh call, and a request whose 401 arrives after another one already refreshed just replays with the new token.
  • When refresh (or headers) throws, or the replay is rejected again, the request fails with a SlingAuthException (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 UNAUTHENTICATED event) refreshes and reopens the connection once; if that fails, the SlingAuthException is a stream error and the subscription follows its usual retryAfter path.

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 retryIf says 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.

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.

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.

SlingException.isNetworkUnreachable (exactly SlingNetworkException) tells “the device could not reach the server” from every answer the server gave:

if (error?.isNetworkUnreachable ?? false) showOfflineBanner();

Each attempt’s outcome is classified (see the error model):

  • The transport throws: http.ClientException → SlingNetworkException, a TimeoutException → 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 → SlingGraphQLException with the errors.
  • errors alongside data → a partial SlingGraphQLException; what happens to the errored paths is the scopes’ errorPolicy (see Loading states & errors).

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.

Terminal window
flutter pub add sling_gql_link gql_link gql_http_link
import '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.

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:.