Skip to main content

Errors & retries

The SDK throws exactly two exception classes, sealed under OptoLinkException: no per-status subclasses and no result objects. They come from com.optomatica.optolink.error:

import com.optomatica.optolink.error.OptoLinkApiException
import com.optomatica.optolink.error.OptoLinkConnectionException
import com.optomatica.optolink.error.OptoLinkException

The exception pair​

OptoLinkApiException: any non-2xx response:

PropertyTypeMeaning
statusIntHTTP status code
messageStringThe backend error message
requestIdString?Present when the error envelope carries one; the SDK doesn't send x-request-id, so this is normally null
detailsList<OptoLinkErrorDetail>?Present on 400 validation failures only; entries are OptoLinkErrorDetail(message, field?)
retryAfterSecondsLong?Parsed from 429 bodies (see below); null on every other status
rawString?The full response body, verbatim

OptoLinkConnectionException: the request never got an HTTP response (connect failure, timeout, or reset). The underlying IOException is attached as the cause; a timeout surfaces as OptoLink request timed out.

try {
OptoLink.instance().setIdentity("user-4711")
} catch (e: OptoLinkConnectionException) {
// network or timeout: safe to retry later
} catch (e: OptoLinkApiException) {
Log.e("OptoLink", "status=${e.status}", e)
}

Non-JSON error bodies (a proxy's HTML 502, an empty body) still throw OptoLinkApiException; message falls back to OptoLink request failed with status <status> and 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 exception 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 appears only when the request carried an x-request-id header — the SDK does not send one by default.

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.