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:
| Property | Type | Meaning |
|---|---|---|
status | Int | HTTP status code |
message | String | The backend error message |
requestId | String? | Present when the error envelope carries one; the SDK doesn't send x-request-id, so this is normally null |
details | List<OptoLinkErrorDetail>? | Present on 400 validation failures only; entries are OptoLinkErrorDetail(message, field?) |
retryAfterSeconds | Long? | Parsed from 429 bodies (see below); null on every other status |
raw | String? | 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
| 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.