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:
| Property | Type | Meaning |
|---|---|---|
status | Int | HTTP status code |
requestId | String? | Present when the error envelope carries one |
details | [OptoLinkErrorDetail]? | Present on 400 validation failures only; entries are OptoLinkErrorDetail(message, field?) |
retryAfterSeconds | Int? | Parsed from 429 bodies (see below); nil on every other status |
raw | String? | 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
| Status | message | When |
|---|---|---|
400 | Validation failed + details[] | Body validation, e.g. properties exceeds 10240 bytes when serialized — reduce the payload size |
400 | eventName must be longer than or equal to 1 characters | Empty event name |
400 | deviceId must be longer than or equal to 8 characters | Malformed device id |
401 | Missing Authorization header / Invalid Authorization format / Invalid API key | Malformed, unknown, or revoked key |
403 | API key tier 'SERVER' is not permitted on this endpoint (requires 'CLIENT') | Wrong-tier key; the SDK needs a CLIENT-tier opl_sdk_… key |
404 | Unknown deviceId <id> — call /sdk/session first | Identity/events for a device that hasn't checked in; initialize handles the session call for you |
404 | Link not found | Unknown short code or org key at resolution time |
429 | Rate 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:
| Endpoint | Limit |
|---|---|
POST /match (first-launch deferred match) | 30/min per IP |
POST /sdk/events | 600/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 / timeout | retry | retry | no: fire-and-forget; the next app open retries |
429 | retry, sleeping retryAfterSeconds | retry, same | no |
5xx | retry | never: a retried event that already landed would double-count | no |
Other 4xx | never (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.