Errors
The inklet SDK error hierarchy and the service error codes behind it — every class, every code, and what to do when you catch one.
Every error the SDK produces extends InkletError, which preserves the
service's own code, the HTTP status, a request ID, and any structured details.
class InkletError extends Error {
readonly code: string;
readonly status: number | undefined;
readonly requestId: string | undefined;
readonly details: Readonly<Record<string, unknown>> | undefined;
readonly idempotencyKey: string | undefined;
toJSON(): Record<string, unknown>;
}toJSON() makes it safe to log the whole thing:
catch (error) {
if (error instanceof InkletError) {
logger.error({ err: error.toJSON() }, "inklet push failed");
}
}requestId can be handed to inklet support without exposing anything secret.
It is the fastest way to have a specific failure looked up, and the service
returns it in the X-Request-Id header on every response.
Hierarchy
Error
└── InkletError
├── ConfigurationError invalid_configuration
├── BrowserEnvironmentError browser_environment
├── AuthenticationError
│ ├── AuthenticationFailedError authentication_failed
│ ├── InvalidSecretKeyError invalid_secret_key
│ └── RevokedSecretKeyError revoked_secret_key
├── PermissionDeniedError permission_denied
│ └── SubscriptionRequiredError subscription_required
├── NotFoundError not_found
├── ConflictError conflict
├── PayloadTooLargeError payload_too_large
├── RateLimitError rate_limited
├── AssetUploadError asset_upload_failed
├── AnalysisFailedError analysis_failed
├── NoChangeError analysis_no_change
├── MultiplePresentationsError analysis_multiple_presentations
├── OperationTimeoutError operation_timed_out
├── OperationAbortedError operation_aborted
├── ApiError api_error
├── InvalidResponseError invalid_response
└── NetworkError network_error
└── RequestTimeoutError request_timed_outThe codes above are the SDK's own defaults. For anything the service answered,
error.code carries the service's code, not the default — a 403 from the
plan gate is a PermissionDeniedError whose code is plan_upgrade_required.
Thrown locally, before any request
| Class | When |
|---|---|
ConfigurationError | Bad options, bad asset, bad limit, bad idempotency key, a bad timeout, an id of . or .., a path that would leave the origin |
BrowserEnvironmentError | A document exists — this is not a trusted environment |
These are programming errors. Retrying will not help.
Mapped from the HTTP status
| Status | Class |
|---|---|
| 401 | AuthenticationFailedError, RevokedSecretKeyError, or InvalidSecretKeyError |
| 403 | PermissionDeniedError — or SubscriptionRequiredError when the code says so |
| 404 | NotFoundError |
| 409 | ConflictError |
| 413 | PayloadTooLargeError |
| 429 | RateLimitError |
| anything else | ApiError |
So a 402 or a 422 reaches you as an ApiError with the service's code and
status intact. Branch on error.code for those.
RateLimitError and ApiError also carry retryAfterMs: the response's
Retry-After, in milliseconds, or null when it sent none. A response that is
not JSON at all — a proxy's error page, say — becomes a short message with its
status and a brief excerpt, never the whole page.
Service error codes
The service answers with one envelope everywhere:
{
"error": {
"code": "invalid_request",
"message": "…",
"requestId": "req_…",
"details": {}
}
}details is omitted when there is nothing to put in it.
| Status | code | Typical cause |
|---|---|---|
| 400 | invalid_request | A malformed body, an out-of-range parameter, a missing Idempotency-Key, or an unknown endpoint |
| 400 | invalid_asset | A direct Analysis whose Content is not exactly one PNG or JPEG |
| 401 | authentication_failed | The token is missing, invalid, or revoked |
| 402 | payment_required | The subscription exists but its payment did not land |
| 403 | plan_upgrade_required | The plan does not include this feature |
| 403 | access_denied | The token owner does not own that resource |
| 404 | display_not_found · content_not_found · presentation_not_found · analysis_not_found | Unknown id, or one belonging to someone else |
| 404 | archive_not_found | The Analysis has no run archive |
| 409 | idempotency_conflict | The key was already used with a different body |
| 409 | invalid_state | An identical request is still in flight, or the resource cannot do this now |
| 409 | asset_not_uploaded | An analyzed Content still has assets missing |
| 409 | analysis_in_progress | The user is over the queued-Analysis limit |
| 409 | presentation_not_deliverable | That Presentation cannot go on that panel |
| 413 | asset_too_large | An asset exceeds the supported size |
| 422 | no_compatible_display | A targetless Analysis with no usable display on the account |
| 422 | display_incompatible | The named display has no usable geometry |
| 429 | quota_exceeded | A plan quota ran out; details carries quota, limit, used, resetAt |
| 429 | rate_limited | Too many requests; retry after Retry-After |
| 500 | internal_error | Something went wrong on our side |
| 503 | processing_unavailable | A dependency is not available on this deployment |
422 no_compatible_display
POST /analyses refuses synchronously when you give no target and the
account has no display the agent could route to:
try {
await inklet.analyze({ contentIds: [content.id] });
} catch (error) {
if (error instanceof ApiError && error.code === "no_compatible_display") {
// Bind a display, or pass an { output } target instead.
}
}It is raised before any quota is reserved and before the Analysis row is written, and the idempotency key is released — so the same key works again once a display is bound.
409 presentation_not_deliverable
Returned by displays.setCurrent() when the
Presentation cannot go on that panel. details is
{ displayId, reason, presentationId }:
reason | Meaning |
|---|---|
targetless | The Presentation has no target display at all. |
not_delivered | It was never delivered to any display. |
other_display | It was delivered to a different panel. |
not_rendered | It has no rendered image yet. |
Branch on details.reason, never on the message.
Idempotency conflicts
if (error instanceof ConflictError) {
if (error.code === "idempotency_conflict") {
// Same key, different body. Pick a new key or send the original body.
}
if (error.code === "invalid_state") {
// The first call with this key has not answered yet. Wait and re-read.
}
}See Idempotency.
Plan and quota refusals
import { ApiError, PermissionDeniedError, RateLimitError } from "@inklethq/sdk";
try {
await inklet.push.auto({ assets });
} catch (error) {
if (error instanceof PermissionDeniedError && error.code === "plan_upgrade_required") {
// Upgrade in the inklet portal, then retry with the same PAT.
}
if (error instanceof ApiError && error.code === "payment_required") {
// The card needs attention in the billing portal.
}
if (error instanceof RateLimitError && error.code === "quota_exceeded") {
// error.details: { quota, limit, used, resetAt, currentTier }
}
}details on a plan refusal carries feature, requiredTier, currentTier,
and status. None of these are transient: retrying before the plan changes will
not help. See Plans.
SubscriptionRequiredError is the PermissionDeniedError subclass for a
service code of subscription_required. inklet Cloud answers plan refusals
with plan_upgrade_required and payment_required today, so catch
PermissionDeniedError and branch on error.code rather than on the
subclass.
Analysis outcomes that throw
| Class | When |
|---|---|
AnalysisFailedError | analyses.wait() found the Analysis failed. details has stage, retryable, backendCode, and analysisId. |
NoChangeError | presentations.waitUntilReady() was handed an Analysis that completed as no_change. details has analysisId and reason. |
MultiplePresentationsError | presentations.waitUntilReady() was handed an Analysis that produced several Presentations — one pinned to several displays, say. Carries analysisId and presentationIds; use analyses.wait() for those. |
NoChangeError is not a failure of the service — it is a normal completion
that the caller required a Presentation from. It is only reachable for an
Analysis that named no contentIds.
Waiting, aborting, and timing out
| Class | When |
|---|---|
OperationTimeoutError | A wait* helper ran out of timeoutMs. The work is not cancelled. |
OperationAbortedError | The AbortSignal you passed fired. The request in flight is cancelled, and the signal's reason is the error's cause. |
See Timeouts and retries.
Transport and parsing
| Class | When |
|---|---|
NetworkError | No response arrived: the service was unreachable, or the connection closed mid-response |
RequestTimeoutError | A NetworkError: one request ran past its timeout. Carries timeoutMs. |
InvalidResponseError | A 2xx response the SDK could not parse or trust |
The SDK validates the shape of every response and refuses to hand you a
half-parsed object. It is deliberately not strict about values inklet may
add later: a state, mode, or event type it has never seen reads through
as a string instead of failing. If you see InvalidResponseError
consistently, the client and service versions are probably out of step.
AssetUploadError
Thrown when binary assets still could not be uploaded after the SDK refreshed their tickets and retried:
class AssetUploadError extends InkletError {
readonly contentId: string | undefined;
readonly failedAssetIndexes: readonly number[];
}import { AssetUploadError } from "@inklethq/sdk";
try {
await inklet.contents.upload({ assets });
} catch (error) {
if (error instanceof AssetUploadError) {
for (const index of error.failedAssetIndexes) {
console.error("failed to upload:", assets[index]);
}
}
}failedAssetIndexes maps back to the array you passed in, and cause is the
underlying failure of the first asset that still failed.
idempotencyKey on errors
Every error from a call that sent an Idempotency-Key carries it — including
one the SDK generated because you passed none. A call that failed after sending
may still have created something: the Content was stored before its upload
broke, or the Analysis was accepted and only the response was lost. Retry with
idempotencyKey: error.idempotencyKey and the same input to pick up what the
first attempt created instead of creating it twice. See
Idempotency.
A practical handler
import {
AuthenticationError,
ConfigurationError,
InkletError,
NetworkError,
PermissionDeniedError,
RateLimitError,
} from "@inklethq/sdk";
async function push() {
try {
return await inklet.push.auto({ assets });
} catch (error) {
if (error instanceof ConfigurationError) {
throw error; // our bug — fail loudly, do not retry
}
if (error instanceof AuthenticationError) {
await alertOperator("inklet token needs rotating");
throw error;
}
if (error instanceof PermissionDeniedError) {
await alertOperator(`inklet refused: ${error.code}`);
throw error; // not transient; the plan has to change first
}
if (error instanceof RateLimitError && error.code === "quota_exceeded") {
throw error; // only time or an upgrade clears this — see details.resetAt
}
if (error instanceof RateLimitError || error instanceof NetworkError) {
// Transient. Reuse the key so the retry cannot create a second push.
return scheduleRetry({ idempotencyKey: error.idempotencyKey });
}
if (error instanceof InkletError) {
logger.error({ err: error.toJSON() });
}
throw error;
}
}Credential redaction
Before an error reaches you, the SDK replaces any occurrence of your token in
the message with [REDACTED]. Error messages are safe to log — but note this
covers the message, not anything you attach yourself.