Fallbacks When the API Is Unreachable
What the WarpLink SDKs do when the API cannot be reached, plus a fallback pattern with code for iOS, Android, React Native, and Flutter.
The SDK needs the API to turn a tapped link into a destination. When the API cannot be reached, the tap still opens your app, because the operating system opens it from the Universal Link or App Link. What is missing is the destination. The SDK reports an error, and your app decides what to show. This page describes what the SDK does and gives a fallback pattern you can copy.
Behavior below is for SDK 1.1.0.
What the SDK does
| Step | Behavior |
|---|---|
| Attempts | A link resolve or a deferred check is tried up to 3 times. |
| Time limits | The first attempt has a 4 second limit. Each retry has a 3 second limit. |
| Waits | The SDK waits about 0.5 seconds before the second attempt and about 1 second before the third, plus up to 0.15 seconds of random jitter. |
| Retry budget | Before each retry the SDK checks the time spent since the first attempt, and starts no retry once 12 seconds have passed. This is a budget checked between attempts, not a hard deadline. Every attempt also has its own time limit. |
| What is retried | Dead or flaky connections, timeouts, DNS failures, and 5xx responses. |
| What is not retried | Refusals. A missing link, a password-protected link, an expired link, a rejected key, and any other 4xx are answered on the first attempt. The 1.1.0 SDKs do not retry a 429 either. |
| Duplicate taps | A failed resolve releases the duplicate-tap guard, so tapping the same link again resolves it again. |
| Delivery | After the last attempt fails, the error goes to your onLink callback. Your app keeps running. |
| Deferred check | A deferred check that fails on first launch is not marked complete, so the next launch tries again. It reaches your callback as an error too, and it is a report, not an instruction to act. |
With the limits above, the worst case is about 11.8 seconds: 4 + 0.5 + 3 + 1 + 3 seconds of attempts and waits, plus up to 0.3 seconds of jitter. The figures match the SDK source for iOS and Android. React Native and Flutter use the native SDKs underneath, so they behave the same.
A custom link domain that you declare in linkDomains is recognized with no network on the first launch. Domains the server returns from GET /v1/sdk/validate are cached after the first successful call.
Why not retry in a loop
The SDK has already retried within its budget. A tight loop of your own adds load to a service that is probably struggling and keeps the user waiting. Show your default screen first, then let a manual action or the next launch try again.
Build the fallback
Two kinds of failure reach your callback, and they need different handling.
- A resolve failure. The user just tapped a link, so they expect to land somewhere. Fall back as described below.
- A deferred-check failure. This is the background attribution check on first launch. Nobody tapped anything in the app, so do nothing visible. Never navigate away from the screen the user is on because of a late attribution error.
The samples tell them apart by recording the tapped URL, with the time, when the operating system delivers it. An error counts as a resolve failure only if a link was tapped in the last 30 seconds. Otherwise the sample does nothing.
For a resolve failure, use three layers in this order.
- Read the URL yourself. The URL has the form
https://<host>/<slug>?<query>. The path is one segment, the slug, and it names no screen. The screen lives in the link's configuration on our servers. What you can read offline is the query string. If you add your own parameters to the links you share, such as?screen=pricing, route on them. - Use a cached route. After every successful resolve, store the route. A link is unique by host and slug, so the same slug on two domains can be two different links. Key the cache by host, path, and query string, so that query values that change routing get their own entry. Store a timestamp with each route and ignore entries older than 30 days, because an edited link can leave a cached route out of date.
- Open the default screen. If neither layer yields a route, open the screen the app opens on its own. A failed resolve must never show a blocking error.
Do not treat a fallback route as an attribution result. A route recovered from your cache or from the URL says nothing about who installed the app or where they came from.
A successful resolve clears the pending tapped URL, so a later deferred-check failure can never navigate. In each sample, router stands for your own navigation code.
iOS (Swift)
import WarpLink
final class LinkFallback {
private let defaults = UserDefaults.standard
private let maxAge: TimeInterval = 30 * 24 * 3600
private var tapped: (url: URL, at: Date)?
func remember(_ url: URL) { tapped = (url, Date()) }
private func key(_ url: URL) -> String {
"route.\(url.host ?? "")\(url.path)?\(url.query ?? "")"
}
func save(_ link: WarpLinkDeepLink) {
guard let url = tapped?.url else { return }
defaults.set(
["route": link.deepLinkUrl ?? link.destination,
"at": Date().timeIntervalSince1970],
forKey: key(url)
)
tapped = nil
}
/// A route after a failed resolve, or nil when no link was just tapped.
func recover() -> String? {
guard let (url, at) = tapped, Date().timeIntervalSince(at) < 30 else { return nil }
tapped = nil
if let screen = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first(where: { $0.name == "screen" })?.value { return screen }
if let entry = defaults.dictionary(forKey: key(url)),
let route = entry["route"] as? String,
let saved = entry["at"] as? TimeInterval,
Date().timeIntervalSince1970 - saved < maxAge { return route }
return "home"
}
}
let fallback = LinkFallback()
// SwiftUI: record the URL, then hand it to the SDK.
// .onOpenURL { url in fallback.remember(url); WarpLink.open(url) }
WarpLink.configure(
apiKey: "wl_live_yoursdkkeyhere000000000000000000",
options: WarpLinkOptions(onLink: { result in
switch result {
case .success(let link?):
fallback.save(link)
router.open(link.deepLinkUrl ?? link.destination)
case .failure(.networkError), .failure(.serverError):
if let route = fallback.recover() { router.open(route) }
default:
break
}
})
)
Android (Kotlin)
import android.content.SharedPreferences
import android.net.Uri
import app.warplink.WarpLink
import app.warplink.WarpLinkDeepLink
import app.warplink.WarpLinkError
import app.warplink.WarpLinkOptions
class LinkFallback(private val prefs: SharedPreferences) {
private val maxAgeMs = 30L * 24 * 3600 * 1000
@Volatile private var tapped: Uri? = null
@Volatile private var tappedAt = 0L
fun remember(uri: Uri?) { tapped = uri; tappedAt = System.currentTimeMillis() }
private fun key(uri: Uri) = "route.${uri.host}${uri.path}?${uri.query.orEmpty()}"
fun save(link: WarpLinkDeepLink) {
val uri = tapped ?: return
val route = link.deepLinkUrl ?: link.destination
prefs.edit().putString(key(uri), "${System.currentTimeMillis()}|$route").apply()
tapped = null
}
/** A route after a failed resolve, or null when no link was just tapped. */
fun recover(): String? {
val uri = tapped ?: return null
if (System.currentTimeMillis() - tappedAt > 30_000) return null
tapped = null
uri.getQueryParameter("screen")?.let { return it }
val cached = prefs.getString(key(uri), null)?.split("|", limit = 2)
if (cached != null && cached.size == 2 &&
System.currentTimeMillis() - (cached[0].toLongOrNull() ?: 0L) < maxAgeMs
) return cached[1]
return "home"
}
}
// Application.onCreate
WarpLink.configure(
context = this,
apiKey = "wl_live_yoursdkkeyhere000000000000000000",
options = WarpLinkOptions(onLink = { result ->
result.onSuccess { link ->
fallback.save(link)
router.open(link.deepLinkUrl ?: link.destination)
}.onFailure { error ->
if (error is WarpLinkError.NetworkError || error is WarpLinkError.ServerError) {
fallback.recover()?.let { router.open(it) }
}
}
})
)
// MainActivity: record the URI before the SDK sees it.
// onCreate: fallback.remember(intent.data)
// onNewIntent: fallback.remember(intent.data); WarpLink.onNewIntent(intent)
React Native
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Linking } from 'react-native';
import { ErrorCodes, WarpLink } from '@warplink/react-native';
const MAX_AGE_MS = 30 * 24 * 3600 * 1000;
let tapped: { url: string; at: number } | null = null;
const remember = (url: string | null) => { if (url) tapped = { url, at: Date.now() }; };
Linking.getInitialURL().then(remember);
Linking.addEventListener('url', ({ url }) => remember(url));
// Host, path, and query together identify the link and its routing values.
const keyOf = (url: string) =>
`route.${url.replace(/^https?:\/\//, '').replace(/#.*$/, '')}`;
async function save(route: string) {
if (!tapped) return;
const { url } = tapped;
tapped = null;
await AsyncStorage.setItem(keyOf(url), JSON.stringify({ route, at: Date.now() }));
}
/** A route after a failed resolve, or null when no link was just tapped. */
async function recover(): Promise<string | null> {
if (!tapped || Date.now() - tapped.at > 30_000) return null;
const { url } = tapped;
tapped = null;
const screen = /[?&]screen=([^&#]+)/.exec(url)?.[1];
if (screen) return screen;
const raw = await AsyncStorage.getItem(keyOf(url));
if (raw) {
const entry = JSON.parse(raw) as { route: string; at: number };
if (Date.now() - entry.at < MAX_AGE_MS) return entry.route;
}
return 'home';
}
WarpLink.configure({
apiKey: 'wl_live_yoursdkkeyhere000000000000000000',
onLink: async ({ deepLink, error }) => {
if (deepLink) {
const route = deepLink.deepLinkUrl ?? deepLink.destination;
await save(route);
navigate(route);
} else if (
error?.code === ErrorCodes.E_NETWORK_ERROR ||
error?.code === ErrorCodes.E_SERVER_ERROR
) {
const route = await recover();
if (route) navigate(route);
}
},
}).catch((e) => console.error('WarpLink configure failed:', e));
AsyncStorage is one choice of storage. Any key-value storage works.
Flutter
In automatic mode the Flutter plugin does not hand the tapped URL to your callback. Use the URL your own router or link listener receives, and resolve it with WarpLink.handleDeepLink, which throws a typed exception on failure. Because you call it for a link the user tapped, every failure here is a resolve failure. A deferred-check failure arrives as an ErrorEvent in onLink, and needs no navigation.
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:warplink_flutter/warplink_flutter.dart';
const _maxAge = Duration(days: 30);
String _key(Uri uri) => 'route.${uri.host}${uri.path}?${uri.query}';
Future<String> routeFor(String url) async {
final uri = Uri.parse(url);
final prefs = await SharedPreferences.getInstance();
try {
final link = await WarpLink.handleDeepLink(url);
if (link == null) return 'home';
final route = link.deepLinkUrl ?? link.destination;
await prefs.setString(
_key(uri),
jsonEncode({'route': route, 'at': DateTime.now().millisecondsSinceEpoch}),
);
return route;
} on WarpLinkNetworkException {
return _recovered(prefs, uri);
} on WarpLinkServerException {
return _recovered(prefs, uri);
}
}
String _recovered(SharedPreferences prefs, Uri uri) {
final screen = uri.queryParameters['screen'];
if (screen != null) return screen;
final raw = prefs.getString(_key(uri));
if (raw != null) {
final entry = jsonDecode(raw) as Map<String, dynamic>;
final age = DateTime.now().millisecondsSinceEpoch - (entry['at'] as int);
if (age < _maxAge.inMilliseconds) return entry['route'] as String;
}
return 'home';
}
When you resolve links yourself this way, configure the plugin with automaticDeepLinks: false, so that one code path owns navigation. See the Flutter SDK for the full setup.
Test it
- Put the device in airplane mode.
- Tap a link that was resolved earlier, and confirm the app opens on the cached route.
- Tap a link that was never resolved, and confirm the app opens on the default screen.
- Turn airplane mode off, tap the same link again, and confirm it resolves.
What this does not cover
- Attribution on first launch. If the API is down at first launch, the deferred check fails and tries again on the next launch. A user who opens the app only once in that window is not attributed.
- Custom parameters. Parameters that live on the link, not on the URL, are not available offline unless you cached them.
- Password-protected links. The SDK returns a password-required error. Open the short URL in a browser, as described in the SDK error handling guide.
For what keeps working on our side during an outage, see Architecture and failure modes.
Abuse Protection and Threat Model
What an extracted SDK key can do, how fabricated installs, replay, and referral manipulation work, which controls exist, and how to verify rewards server-side.
SDK Data Flow
For SDK 1.1.0: the fields each SDK sends on validate, resolve, and attribution match, what the server stores, redacted payloads, and URL redaction advice.