Skip to main content

06 - Network

Concept​

1. Why​

Every app rebuilds the same HTTP setup. The hard parts are also the easiest to get wrong:

  • Token refresh. Ten requests hit 401 at once, two refreshes race, and a rotating refresh token gets invalidated.
  • Translating HTTP errors into something a page can act on.
  • Timeouts on every request.
  • Logging that never leaks a token.
  • Never sending a token to a host it doesn't belong to.

sinew_network builds a configured client (Dio on Flutter, Ktor on Kotlin) with those parts done once, plus the source guard processApiCall. The app keeps writing its API clients directly on Dio or Ktor. Sinew doesn't hide the HTTP library.

2. Shape​

What the app supplies​

SinewConfig(
baseUrl: String,
connectTimeout = 15s,
readTimeout = 30s, // reads
writeTimeout = 60s, // writes: repeating them is costly, so they get longer
logHttp = false, // debug builds only
anonymousPaths: Set<String> = {}, // never get a token, never trigger a refresh (sign-in, register, OTP, password reset, refresh).
// Matched relative to baseUrl's path: with baseUrl ".../v1", "/auth/login" matches "/v1/auth/login".
locale: () -> String, // read per request, sent as Accept-Language
defaultHeaders: () -> Map<String, String> = {}, // app version, API key, device id: read per request. Every name here is redacted by devtools.
errorBodyReader: ErrorBodyReader = ErrorBodyReader.default,
)

interface TokenSource {
accessToken(): String? // in memory
refresh(): RefreshOutcome // gets a new pair from the backend and stores it
clear() // the session ended: drop tokens, void any refresh still running, tell the app's session service
}

RefreshOutcome = Refreshed | Rejected | Unavailable(cause: AppException)
// Rejected: the backend refused the refresh token, or the session ended while refreshing → the session is over
// Unavailable: the refresh couldn't be completed (offline, timeout, 5xx) → the session is kept

// Sinew's ready implementation: the app supplies only the backend call and where the refresh token is kept
class SessionTokenSource(store: RefreshTokenStore, call: RefreshCall) : TokenSource
interface RefreshTokenStore { read(): String?, write(token: String), delete() } // the app adapts its SecureStore in one line
interface RefreshCall { refresh(refreshToken: String): RefreshResult } // the app's backend call, on a client without the auth layer
RefreshResult = Tokens(access: String, refresh: String) | Rejected | Unavailable(cause: AppException)

interface ErrorBodyReader { read(status: Int, body: String?): ErrorBody? }
ErrorBody(message: String?, fieldErrors: Map<String, List<String>>?)
// default: reads JSON "message" and "errors" ({ field: [messages] }), returns null for anything unreadable

The app builds one client per backend (SinewHttp.dio(config, tokenSource) / SinewHttp.client(config, tokenSource)) and registers each under a name in its DI.

The layers, in order​

Each request passes through these layers, outermost first. They're Dio interceptors on Flutter and Ktor plugins on Kotlin.

#LayerDoes
1TimeoutSets the deadline by method: reads use readTimeout, writes (POST, PUT, PATCH, DELETE) use writeTimeout. Every request has one.
2HeadersAdds Accept: application/json, Accept-Language from locale(), and defaultHeaders().
3AuthAdds Authorization: Bearer … for requests to its own host, except anonymous paths. Refreshes on 401 (rules below).
4Fault injectionDevtools hook: forces a status, a delay or a dropped connection on a matching path. Does nothing without devtools.
5RecorderDevtools hook: records the request and response, redacted when recorded. Does nothing without devtools.
6Redacting logLogs method, path, status and duration when logHttp is on. Redacts Authorization, cookies and the bodies of auth endpoints.

Fault injection sits inside auth, so a forced 401 exercises the real refresh path.

The auth layer​

RuleWhy
Single-flight: while a refresh runs, other 401s wait for its outcome instead of starting their ownParallel refreshes race, and with rotating refresh tokens the second invalidates the first
A 401 for a request sent with an older token than the current one replays at once, without refreshingA refresh already finished while that request was in flight. Refreshing again would break "exactly one refresh".
The refresh runs in a scope owned by the client, not by the first callerIf the first caller is cancelled (its page closed), the other waiters still get the outcome
A replay goes out through the auth layer's own path, and its errors are handed back to the caller like any other errorA failing replay can never wait on the auth layer that is waiting on it
A replayed request is marked. A second 401 on it fails instead of refreshing again.Otherwise a replay waits on a refresh that waits on the replay: a deadlock
Anonymous paths never get a token and never trigger a refreshA 401 there means "wrong credentials", not "expired token"
The refresh call itself bypasses the auth layerA failing refresh must not refresh itself
Rejected ends the session: clear(), then every queued request fails with UnauthorizedExceptionThe app's session service routes to sign-in
Unavailable keeps the session: queued requests fail with the cause (NoInternetConnectionException, RequestTimeOutException, ServiceUnavailableException)A refresh that fails because the network or the backend is down must not sign the user out. The page shows a retryable error; the next request tries again.
A token is attached only to requests whose host is the client's baseUrl hostAn absolute URL to a CDN or a storage bucket never receives the user's token
reset() on sign-out clears the queue and the refreshing flag, and clear() voids a running refresh through the session epochNothing leaks into the next user's session, and a late refresh result is never stored
A redirect to another host is followed without the Authorization headerA redirect can't carry the user's token off the backend's host

Sign-out during a refresh: the session epoch​

A refresh is a network call, so it can finish after the user signed out. If it then stored its new tokens, the user (or the next user on the device) would be silently signed back in. SessionTokenSource prevents that with a session epoch, a counter that clear() increments:

refresh():
epoch = currentEpoch // remember which session started this refresh
result = call.refresh(store.read())
lock:
if currentEpoch != epoch: return Rejected // signed out meanwhile: discard the result, store nothing
if result is Tokens: store.write(result.refresh), access = result.access, return Refreshed
…
clear():
lock: currentEpoch += 1, access = null, store.delete()

The check and the write happen under one lock, so no ordering of clear() and a finishing refresh can store tokens after sign-out. An app that writes its own TokenSource must give clear() the same guarantee; the rule is part of the interface's contract.

The source guard​

processApiCall(module, function, call: () -> Envelope<R>): Result<R> =
processCall(module, function, Repository, apiHandlers(module, function)) {
envelope = call()
if not envelope.success:
throw envelope.errors != null
? ValidationException(module, Repository, function, fieldErrors = envelope.errors, message = serverOrLocal(envelope.message, "sinew.error.validation"))
: ApiErrorException(module, Repository, function, message = serverOrLocal(envelope.message, "sinew.error.generic"))
envelope.toDomain() // inside the guard: a mapping bug becomes ParseException
}

processOptionalApiCall(module, function, call): Result<R?> // the same, except a 404 is success(null)

serverOrLocal(text, key) = text is not blank ? Server(text) : Local(key, fallback = English for key)

apiHandlers turns HTTP library errors into the Exception types, first match wins:

#MatchesProduces
1a cancelled request (Flutter)CancelException. On Kotlin, cancellation is rethrown before handlers run.
2a connect, send or receive timeoutRequestTimeOutException
3no connection, DNS failure, connection refused or resetNoInternetConnectionException
4a response with status 400 or 422 whose body has field errorsValidationException
5status 401 (after the auth layer gave up)UnauthorizedException
6403, 404, 409ForbiddenException, NotFoundException, ConflictException
7429RateLimitedException, with retryAfter from the Retry-After header (seconds or an HTTP date)
8500, 502 / 503, 504ServerException / ServiceUnavailableException
9any other non-2xx status with a readable messageApiErrorException
10any other non-2xx statusUndefinedErrorResponseException
11JSON that doesn't fit the DTO, including an enum value the DTO can't fall back fromParseException
12EnvelopeDataMissing (shared)ParseException

For rows 4–10, the client has already read the error body: when a response fails, its error layer runs the client's own errorBodyReader.read(status, body) and attaches the result to the error as an HttpStatusError(status, errorBody, retryAfter). The reader belongs to each client's SinewConfig, so the handlers never need it; they classify HttpStatusError by status. A readable message becomes Server(text); otherwise the type's local key is used.

Retry and polling​

HelperDoesRules
retryWithBackoff(module, function, maxAttempts = 3, initialDelay = 500 ms, maxDelay = 5 s, retryIf = { it.retryable }, block)Runs block (which returns a Result) again after a retryable failure, with exponential backoff and full jitter. A 429 waits at least retryAfter. When it gives up, it returns the last failure unchanged, so the user sees the real reason.Idempotent requests only (reads, and writes carrying an Idempotency-Key). Retry in one layer only: never around a call that's already retried.
poll(module, function, interval = 2 s, maxInterval = 10 s, timeout = 60 s, until: (T) -> Boolean, block)Calls block until until(data) is true, growing the interval by 1.5× with jitterRetryable failures keep polling until the timeout. A non-retryable failure stops at once and is returned. The timeout gives PollingTimeOutException.

There's no automatic retry in the client. A failed request shows its error with a retry action, and the user decides.

Network checker​

NetworkChecker { isOnline(): Boolean, changes: stream of NetworkStatus }   NetworkStatus { Online, Offline }

It's for the UI (an offline banner, disabling a submit button). It never decides a request's error type. That comes from the request's own failure, because "connected to Wi-Fi" doesn't mean "the backend is reachable".

Environment rules​

RuleWhy
The base URL and keys come from the app's build configuration, passed in SinewConfigOne source per environment. Nothing hard-coded in API clients.
Accepting invalid certificates is possible only through a debug-only option that release builds can't setA leftover "accept any certificate" disables TLS checks in production
Certificate pinning, when an app wants it, is configured on the client's engine (each platform note says how)One place, next to the rest of the client setup
logHttp is off by default and must never be on in release buildsLogs end up in device logs and bug reports

3. API​

NameSignature (pseudocode)For
SinewConfigas aboveper-client configuration
TokenSourceaccessToken(): String?, refresh(): RefreshOutcome, clear() (voids a running refresh)where tokens live, how they refresh
SessionTokenSourceSessionTokenSource(store: RefreshTokenStore, call: RefreshCall)the ready, epoch-guarded implementation
RefreshTokenStore, RefreshCall, RefreshResultas abovewhat the app supplies to SessionTokenSource
RefreshOutcomeRefreshed | Rejected | Unavailable(cause)what a refresh attempt concluded
ErrorBodyReader, ErrorBodyread(status, body): ErrorBody?, ErrorBodyReader.defaultreading the backend's error messages
SinewHttpFlutter SinewHttp.dio(config, tokenSource, extraInterceptors = []): Dio. Kotlin SinewHttp.client(config, tokenSource, engine = platform default, configure = {}): HttpClient.building one client
resetAuthSinewHttp.resetAuth(client)sign-out: clear the auth layer's queue and flag
processApiCallprocessApiCall(module, function, call: () -> Envelope<R>): Result<R>the source guard for HTTP calls
processOptionalApiCallprocessOptionalApiCall(module, function, call): Result<R?>404 → null
retryWithBackoffas above, → Result<T>explicit retries
pollas above, → Result<T>bounded waiting for a backend process
NetworkCheckerisOnline(), changesconnectivity for the UI
devtools hooksRecorderLayer, FaultInjectionLayer, no-ops until devtools install themsee DevTools

4. Build steps​

  1. SinewConfig, TokenSource, RefreshOutcome, ErrorBodyReader with its default, and tests that the default reader:
    • reads message and errors;
    • returns null for HTML, empty or non-JSON bodies.
  2. The timeout and headers layers, tested against a mock server:
    • read and write deadlines;
    • Accept-Language follows a locale change between two requests;
    • default headers are read per request.
  3. The auth layer, test first, against a mock server that counts refresh calls. Tests:
    • ten parallel 401s cause exactly one refresh, and all ten replay once;
    • Rejected clears the session and fails all ten with UnauthorizedException;
    • Unavailable keeps the session and fails them with the cause;
    • a replayed request that gets 401 again fails without a second refresh;
    • anonymous paths never get a token, including with a baseUrl path prefix (/v1);
    • sign-out while a refresh is running: the refresh's tokens are not stored, and waiters fail with UnauthorizedException;
    • a 401 for a request sent with an older token replays without a second refresh;
    • the first caller cancelled mid-refresh: the other waiters still get the outcome;
    • a replay that returns 401, and one that returns 500, fail the caller without blocking later requests;
    • a cross-host redirect drops Authorization;
    • a request to another host never gets a token;
    • sign-out during a refresh fails the queue and leaves no state behind.
  4. processApiCall and processOptionalApiCall, with one test per handler row. That includes success: false with and without errors, a 404 on the optional variant, a 429 with Retry-After in seconds and as a date, and an HTML 502 page.
  5. retryWithBackoff and poll with a virtual clock. Tests:
    • attempts and delays;
    • a non-retryable failure stops at once;
    • a 429 waits at least retryAfter;
    • the polling timeout.
  6. NetworkChecker per platform.
  7. The redacting log, with a test that no logged line contains a bearer token or an auth endpoint's body.

Platforms​

PlatformNotes
Flutter webDio's browser adapter can't set a send timeout or pin certificates. Those settings are ignored there, as documented in the Flutter note.
Kotlin wasmJsKtor's JS engine follows the browser's own TLS and redirect handling. Pinning isn't available.

Done when

  • Every test in step 3 passes against a real HTTP mock on every target.
  • Every handler row has a test that produces its exception type and code.
  • No log line, in any test, contains a bearer token.
  • A refresh failing because the backend is down never signs the user out.

5. Edge cases​

CaseDecided behavior
Ten requests get 401 at onceOne refresh. The others wait and replay once with the new token, or all fail together.
The refresh token was rejectedRejected: the session ends and every waiting request fails with UnauthorizedException.
The refresh call times out or gets a 503Unavailable: the session is kept, waiting requests fail with the cause, and the page shows a retryable error.
The user signs out while a refresh runsThe app calls resetAuth and tokenSource.clear(), which bumps the session epoch. When the refresh finishes, its tokens are not stored (the epoch moved), and waiting requests fail with UnauthorizedException.
A 401 arrives for a request sent before a refresh that has since finishedIt replays with the new token. No second refresh.
The first request that triggered a refresh is cancelledThe refresh keeps running in the client's scope, and the other waiters get its outcome.
The replay itself fails (a second 401, a 500, offline)The caller gets that failure. The auth layer is never blocked by its own replay.
A replayed request gets 401 againIt fails with UnauthorizedException. No second refresh.
A timeout on a non-idempotent write (POST)Returned as RequestTimeOutException, never retried automatically. The app decides; an Idempotency-Key makes a retry safe.
Offline at app startRequests fail with NoInternetConnectionException. The network checker shows offline, and pages offer retry.
A 429 with Retry-AfterRateLimitedException.retryAfter is set, and the message names the wait. retryWithBackoff waits at least that long.
A redirectGET redirects are followed within the same host. A redirect to another host is followed without the Authorization header.
A 502 whose body is an HTML page from a proxyThe error-body reader returns null, so the message is the local sinew.error.server.
An empty message on success: falseTreated as unreadable: the local generic message is shown.
The app's language changes mid-requestThat request carries the old Accept-Language. The next one uses the new locale.
An absolute URL to another host (a CDN image, a pre-signed upload)No Authorization header is attached.

Implementation​

1. Why​

This note builds the base network design on Ktor 3: one configured HttpClient per backend, the six layers as Ktor plugins, a custom auth plugin for the three-way refresh outcome, and processApiCall.

2. Shape​

Building a client​

public object SinewHttp {
public fun client(
config: SinewConfig,
tokenSource: TokenSource,
engine: HttpClientEngineFactory<*> = platformEngine(), // OkHttp (android, jvm), Darwin (ios), Js (wasmJs)
hooks: SinewHttpHooks = SinewHttpHooks.None, // devtools recorder and fault injection
configure: HttpClientConfig<*>.() -> Unit = {}, // the app's extras: pinning, a custom engine setting
): HttpClient = HttpClient(engine) {
expectSuccess = false // Sinew validates responses itself, after SinewAuth has seen them
followRedirects = false // SinewRedirect follows them, dropping the token across hosts
install(ContentNegotiation) { json(SinewJson) } // ignoreUnknownKeys, explicitNulls = false, coerceInputValues = true
install(HttpTimeout) // per request, set by SinewTimeout
defaultRequest { url(config.baseUrl) }
install(SinewTimeout) { read = config.readTimeout; write = config.writeTimeout; connect = config.connectTimeout }
install(SinewHeaders) { locale = config.locale; defaults = config.defaultHeaders }
install(SinewAuth) { source = tokenSource; anonymousPaths = config.anonymousPaths; baseUrl = Url(config.baseUrl) }
install(SinewRedirect) // GET/HEAD only, at most 5 hops
HttpResponseValidator { validateResponse { r -> if (!r.status.isSuccess()) throw HttpStatusError.from(r, config.errorBodyReader) } }
install(SinewFaults) { this.hooks = hooks }
install(SinewRecorder) { this.hooks = hooks }
if (config.logHttp) install(Logging) { level = LogLevel.INFO; sanitizeHeader { it in RedactedHeaders } }
configure()
}
public suspend fun resetAuth(client: HttpClient)
public fun debugExpireToken(client: HttpClient) // devtools only
}

The auth plugin​

Ktor's built-in Auth bearer provider refreshes once for concurrent 401s, but its refresh result is only "new tokens or nothing". Sinew's base contract needs three outcomes (Refreshed, Rejected, Unavailable(cause)), and a failure that carries its cause to every waiting request. So SinewAuth is a small custom plugin on Ktor's HttpSend interceptor:

internal val SinewAuth = createClientPlugin("SinewAuth", ::SinewAuthConfig) {
val cfg = pluginConfig
val refresher = SingleFlightRefresher(cfg.source, scope = CoroutineScope(SupervisorJob())) // owned by the client, not by the first caller
client.plugin(HttpSend).intercept { request ->
val path = request.url.encodedPath.removePrefix(cfg.baseUrl.encodedPath.trimEnd('/')) // anonymous paths are relative to baseUrl's path
val anonymous = path in cfg.anonymousPaths
val ownHost = request.url.host == cfg.baseUrl.host
val sentWith = if (!anonymous && ownHost) cfg.source.accessToken()?.also { request.bearerAuth(it) } else null
val call = execute(request)
if (call.response.status != HttpStatusCode.Unauthorized || sentWith == null || request.attributes.contains(Replayed)) return@intercept call
val outcome = if (cfg.source.accessToken() != sentWith) RefreshOutcome.Refreshed // a refresh already finished meanwhile
else refresher.refresh() // joins the running refresh, or starts one
when (outcome) {
RefreshOutcome.Refreshed -> execute(request.apply {
attributes.put(Replayed, Unit); headers.remove(HttpHeaders.Authorization); cfg.source.accessToken()?.let { bearerAuth(it) } })
RefreshOutcome.Rejected -> { cfg.source.clear(); throw SessionRejected() }
is RefreshOutcome.Unavailable -> throw outcome.cause // already an AppException: passes through
}
}
}
PartDoes
SingleFlightRefresherholds a Mutex and the in-flight Deferred<RefreshOutcome>, started with scope.async. Waiters await it. Cancelling a waiter cancels only its own wait, never the refresh. resetAuth cancels the scope's children and starts fresh.
The sent-token checka 401 for a request sent with an older token replays at once, so ten requests produce exactly one refresh even when some 401s arrive after it finished
The replaygoes through execute like any request. Its failure (a second 401, a 500) is returned to its caller; nothing waits on it, so it can't block the plugin.
SessionRejectedmapped to UnauthorizedException by the handlers

The refresh call itself is made by the TokenSource. With Sinew's SessionTokenSource, the app's RefreshCall uses a separate HttpClient without SinewAuth, so a refresh can never refresh itself.

SessionTokenSource and the session epoch​

public class SessionTokenSource(private val store: RefreshTokenStore, private val call: RefreshCall) : TokenSource {
private val mutex = Mutex()
private var epoch = 0L
@Volatile private var access: String? = null

override fun accessToken(): String? = access
override suspend fun refresh(): RefreshOutcome {
val started = mutex.withLock { epoch }
val refreshToken = store.read() ?: return RefreshOutcome.Rejected
val result = call.refresh(refreshToken)
return mutex.withLock {
if (epoch != started) return@withLock RefreshOutcome.Rejected // signed out meanwhile: store nothing
when (result) {
is RefreshResult.Tokens -> { store.write(result.refresh); access = result.access; RefreshOutcome.Refreshed }
RefreshResult.Rejected -> RefreshOutcome.Rejected
is RefreshResult.Unavailable -> RefreshOutcome.Unavailable(result.cause)
}
}
}
override suspend fun clear(): Unit = mutex.withLock { epoch++; access = null; store.delete() }
}

Redirects​

SinewRedirect follows 301, 302, 303, 307 and 308 for GET and HEAD only, up to 5 hops. Each hop is a new request through HttpSend, so SinewAuth attaches the token only when the hop's host is the client's own. A cross-host hop never carries Authorization.

Timeouts and headers​

PluginDoes
SinewTimeoutFor GET/HEAD, sets requestTimeoutMillis and socketTimeoutMillis to readTimeout. For other methods, writeTimeout. connectTimeoutMillis is always connectTimeout.
SinewHeadersAdds Accept: application/json, Accept-Language: locale(), and defaultHeaders(), read on every request
SinewFaults / SinewRecorderCall hooks. SinewHttpHooks.None makes them do nothing.

The guard and the error body​

Reading a response body is a suspend call, but handlers are plain functions. So the client's response validator reads a failed response's body with its own errorBodyReader and throws a plain HttpStatusError(status, errorBody, retryAfter). The guard's handlers only classify it by status:

public suspend fun <R> processApiCall(module: String, function: String, call: suspend () -> Envelope<R>): Result<R> =
processCall(module, function, ExceptionLayer.Repository, apiHandlers(module, function)) {
val envelope = call() // a failed status already threw HttpStatusError
if (!envelope.success) throw envelope.toApiError(module, function) // ValidationException or ApiErrorException
envelope.toDomain()
}

apiHandlers follows the base table. The connection and timeout predicates are expect functions, because each engine throws its own types:

PlatformNo connectionTimeout
android, jvm (OkHttp)UnknownHostException, ConnectException, NoRouteToHostException, SocketExceptionHttpRequestTimeoutException, SocketTimeoutException, ConnectTimeoutException
ios (Darwin)DarwinHttpRequestException with NSURLErrorNotConnectedToInternet, CannotFindHost, CannotConnectToHost, NetworkConnectionLostthe same with NSURLErrorTimedOut, plus Ktor's timeout types
wasmJs (Js)a fetch TypeError while navigator.onLine is falseKtor's timeout types

Some engines report a timeout as a CancellationException. processCall tells the two apart by checking whether the caller is still active (see Error Model): if it is, the "cancellation" is classified like any other failure, and the timeout predicates above turn it into RequestTimeOutException.

Unknown enum values. SinewJson sets coerceInputValues = true, and DTO enum properties declare a default (val status: OrderStatusDto = OrderStatusDto.Unknown), so an unknown value becomes Unknown instead of failing the whole response. An enum inside a list has no property default; its DTO uses a custom serializer that falls back to Unknown.

Network checker​

PlatformImplementation
AndroidConnectivityManager.registerDefaultNetworkCallback, online when the network has NET_CAPABILITY_VALIDATED
iOSNWPathMonitor, online when the path status is satisfied
JVMpolls NetworkInterface every 5 seconds for an interface that's up and not loopback
wasmJsnavigator.onLine plus the online and offline window events

Retry and polling​

retryWithBackoff and poll use delay, so tests run them on virtual time with runTest. Full jitter uses Random.nextLong(0, backoff).

Certificate pinning and TLS​

Pinning goes in configure, on the engine:

SinewHttp.client(config, tokens, configure = {
engine { /* OkHttp: config { certificatePinner(pins) } Darwin: handleChallenge { … } */ }
})

Sinew offers no option to accept invalid certificates. A local-development setup that needs it configures the engine in the app's debug source set only, so release code can't contain it.

3. API​

NameKotlin
SinewHttp.clientas above, returns HttpClient
SinewHttp.resetAuthpublic suspend fun resetAuth(client: HttpClient)
SinewHttpHookspublic interface SinewHttpHooks { public suspend fun fault(request: HttpRequestData): FaultDecision?; public fun record(exchange: HttpExchange) }, None
processApiCall, processOptionalApiCallpublic suspend fun <R> as above
ErrorBodyReaderpublic fun interface ErrorBodyReader { public fun read(status: Int, body: String?): ErrorBody? }, Default
retryWithBackoff, pollpublic suspend fun <T>
NetworkCheckerpublic interface NetworkChecker { public val status: StateFlow<NetworkStatus> }, NetworkChecker.create(context)

NetworkChecker exposes a StateFlow, so isOnline() is status.value == Online.

4. Build steps​

  1. SinewJson, SinewTimeout, SinewHeaders, tested with Ktor's MockEngine:
    • per-method timeouts;
    • Accept-Language changes between two requests;
    • default headers.
  2. SingleFlightRefresher and SinewAuth, test first with MockEngine and FakeTokenSource. These are the Review Focus 1 tests:
    • ten parallel 401s cause one refresh;
    • Rejected fails all ten with UnauthorizedException;
    • Unavailable fails all ten with its cause and keeps the session;
    • a replayed 401 fails without a second refresh;
    • a replay that returns 500 fails its caller, and the next request still works;
    • a 401 for a request sent with an older token replays without refreshing;
    • the first caller cancelled mid-refresh: the other nine still get Refreshed;
    • signOutMidRefresh: clear() while the refresh is suspended; the refresh's tokens are not stored, all waiters fail with UnauthorizedException;
    • anonymous paths (with a /v1 base path) and other hosts never get a token.
  3. SessionTokenSource alone: the epoch check under the lock, in every interleaving of refresh and clear (driven by a test dispatcher).
  4. SinewRedirect: a same-host redirect keeps the token, a cross-host one drops it, a POST isn't followed.
  5. processApiCall with one test per handler row (including data: null → ParseException reported, and an unknown enum value → Unknown), plus the platform predicate tests on each target, plus an engine-wrapped timeout classified as RequestTimeOutException.
  6. The network checker per platform (instrumented on Android, simulator on iOS, browser test on wasm).
  7. retryWithBackoff and poll on virtual time.

Done when

  • Every test in step 2 passes on all four targets.
  • No test log contains a bearer token.

5. Edge cases​

CaseDecided behavior
Ktor's Auth plugin installed by the app as wellNot supported. It would compete with SinewAuth over 401s.
A request body that can't be replayed (a one-shot stream upload)Not replayed after a refresh: it fails with UnauthorizedException, and the app retries the upload.
The refresh token call uses the same clientIt must not. The TokenSource uses its own client without SinewAuth, documented in the sample.
A HEAD requestUses the read timeout.
Decided by default (revisit during implementation)
  • A custom SinewAuth plugin instead of Ktor's Auth bearer provider, because the three-way RefreshOutcome and the shared failure cause can't be expressed with it.
  • Sinew validates responses itself (expectSuccess = false plus a validator) and follows redirects itself, so the order relative to SinewAuth is known. Verify at S3 that the validator runs after HttpSend interceptors on all four engines.
  • No insecure-certificate option in Sinew. The base note allowed a debug-only option. On Kotlin, the app's debug source set configures the engine itself, which keeps the option out of Sinew's API entirely.
  • NetworkChecker.status as a StateFlow, the Kotlin idiom for "current value plus changes".