inklet docs

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_out

The 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

ClassWhen
ConfigurationErrorBad options, bad asset, bad limit, bad idempotency key, a bad timeout, an id of . or .., a path that would leave the origin
BrowserEnvironmentErrorA document exists — this is not a trusted environment

These are programming errors. Retrying will not help.

Mapped from the HTTP status

StatusClass
401AuthenticationFailedError, RevokedSecretKeyError, or InvalidSecretKeyError
403PermissionDeniedError — or SubscriptionRequiredError when the code says so
404NotFoundError
409ConflictError
413PayloadTooLargeError
429RateLimitError
anything elseApiError

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.

StatuscodeTypical cause
400invalid_requestA malformed body, an out-of-range parameter, a missing Idempotency-Key, or an unknown endpoint
400invalid_assetA direct Analysis whose Content is not exactly one PNG or JPEG
401authentication_failedThe token is missing, invalid, or revoked
402payment_requiredThe subscription exists but its payment did not land
403plan_upgrade_requiredThe plan does not include this feature
403access_deniedThe token owner does not own that resource
404display_not_found · content_not_found · presentation_not_found · analysis_not_foundUnknown id, or one belonging to someone else
404archive_not_foundThe Analysis has no run archive
409idempotency_conflictThe key was already used with a different body
409invalid_stateAn identical request is still in flight, or the resource cannot do this now
409asset_not_uploadedAn analyzed Content still has assets missing
409analysis_in_progressThe user is over the queued-Analysis limit
409presentation_not_deliverableThat Presentation cannot go on that panel
413asset_too_largeAn asset exceeds the supported size
422no_compatible_displayA targetless Analysis with no usable display on the account
422display_incompatibleThe named display has no usable geometry
429quota_exceededA plan quota ran out; details carries quota, limit, used, resetAt
429rate_limitedToo many requests; retry after Retry-After
500internal_errorSomething went wrong on our side
503processing_unavailableA 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 }:

reasonMeaning
targetlessThe Presentation has no target display at all.
not_deliveredIt was never delivered to any display.
other_displayIt was delivered to a different panel.
not_renderedIt 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

ClassWhen
AnalysisFailedErroranalyses.wait() found the Analysis failed. details has stage, retryable, backendCode, and analysisId.
NoChangeErrorpresentations.waitUntilReady() was handed an Analysis that completed as no_change. details has analysisId and reason.
MultiplePresentationsErrorpresentations.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

ClassWhen
OperationTimeoutErrorA wait* helper ran out of timeoutMs. The work is not cancelled.
OperationAbortedErrorThe 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

ClassWhen
NetworkErrorNo response arrived: the service was unreachable, or the connection closed mid-response
RequestTimeoutErrorA NetworkError: one request ran past its timeout. Carries timeoutMs.
InvalidResponseErrorA 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.

On this page