Skip to main content

Errors & retries

The SDK throws exactly two error types, both conforming to the OptoLinkError protocol: no per-status subclasses and no result objects. All types are in the OptoLink module:

import OptoLink

The error pair​

OptoLinkApiError: any non-2xx response:

PropertyTypeMeaning
statusIntHTTP status code
requestIdString?Present when the error envelope carries one
details[OptoLinkErrorDetail]?Present on 400 validation failures only; entries are OptoLinkErrorDetail(message, field?)
retryAfterSecondsInt?Parsed from 429 bodies (see below); nil on every other status
rawString?The full response body, verbatim

OptoLinkApiError conforms to LocalizedError; its errorDescription is the envelope message, or OptoLink request failed with status <status> when the body isn't JSON.

OptoLinkConnectionError: the request never got an HTTP response (connect failure, timeout, or reset). The URLError is attached as underlying.

do {
try await OptoLink.instance().setIdentity(externalId: "user-4711")
} catch let e as OptoLinkConnectionError {
// network or timeout: safe to retry later
} catch let e as OptoLinkApiError {
print("status=\(e.status)")
}

Non-JSON error bodies (a proxy's HTML 502, an empty body) still produce OptoLinkApiError; raw holds the body text.

Never thrown: initialize (network failure during the deferred match is logged via config.logger and startup proceeds) and the links stream (never errors). resolveDeferredLink, setIdentity, clearIdentity, and trackEvent throw.

The error envelope​

Backend failures return a JSON envelope; the SDK lifts its fields onto the error and keeps the whole thing in raw:

{
"statusCode": 404,
"error": "Not Found",
"message": "Link not found",
"details": [{ "message": "…" }],
"timestamp": "2026-09-20T05:30:58.091Z",
"path": "/match",
"requestId": "…"
}

details[] appears only on 400 validation errors. requestId is present on current error bodies but optional in the SDK's parser.

Common statuses​

StatusmessageWhen
400Validation failed + details[]Body validation, e.g. properties exceeds 10240 bytes when serialized — reduce the payload size
400eventName must be longer than or equal to 1 charactersEmpty event name
400deviceId must be longer than or equal to 8 charactersMalformed device id
401Missing Authorization header / Invalid Authorization format / Invalid API keyMalformed, unknown, or revoked key
403API key tier 'SERVER' is not permitted on this endpoint (requires 'CLIENT')Wrong-tier key; the SDK needs a CLIENT-tier opl_sdk_… key
404Unknown deviceId <id> — call /sdk/session firstIdentity/events for a device that hasn't checked in; initialize handles the session call for you
404Link not foundUnknown short code or org key at resolution time
429Rate limit exceeded. Retry after N second(s).Rate limited (see below)

Rate limits (429)​

The API sends no rate-limit headers and no JSON field for the wait. The message carries it (Retry after N second(s)), and the SDK regexes N into retryAfterSeconds, then sleeps that long before retrying within the budget.

The limits behind a 429:

EndpointLimit
POST /match (first-launch deferred match)30/min per IP
POST /sdk/events600/min per key
/sdk/session + identity (shared bucket)60/min per key

The retry matrix​

Defaults: timeout: 10 seconds per attempt, maxRetries: 2 (one shared budget per public call; see the config).

Failure/match, link resolution/sdk/identity, /sdk/identity/clear, /sdk/events/sdk/session
Connection error / timeoutretryretryno: fire-and-forget; the next app open retries
429retry, sleeping retryAfterSecondsretry, sameno
5xxretrynever: a retried event that already landed would double-countno
Other 4xxnever (throws)never (throws)logged

Link resolution and /match are repeat-safe (matching creates nothing; the device-profile upsert is idempotent), so they retry everything transient. Events are user-facing facts, so a landed event is never replayed. /sdk/session is fire-and-forget by design; initialize never blocks on it.

Backoff between attempts: 429 sleeps retryAfterSeconds; every other retryable failure sleeps min(0.5 · 2ⁿ, 5) seconds (n = attempt index). When the budget is exhausted, the last error is thrown, except for /sdk/session, which only logs.