Skip to main content

Errors

The SDK throws a tree of Gisl* error classes for the conditions it recognises. All are instance-checkable (instanceof in TypeScript, instanceof / catch (...) in PHP), and the PHP SDK mirrors the TypeScript surface class-for-class.

Taxonomy

All classes extend the native Error.

GislError ← base, re-thrown from SDK code paths
├─ GislApiError ← any non-2xx / success:false response
│ ├─ GislValidationError ← validation details[] payload (usually 400)
│ ├─ GislBalanceExhaustedError ← 402 balance_exhausted
│ ├─ GislTierRestrictedError ← 403 tier_restriction
│ ├─ GislFeatureTierRestrictedError ← 403 feature_tier_restricted
│ ├─ GislFeatureNotAvailableError ← 422 feature_not_available
│ ├─ GislWorkflowExpiredError ← 422 workflow_expired
│ ├─ GislUploadCapExceededError ← upload exceeds the tier cap (.kind = size|duration)
│ ├─ GislMultipartSessionNotFoundError ← 404 multipart resume: session gone
│ ├─ GislMultipartSessionOwnershipError ← 403 multipart resume: owned by another user
│ ├─ GislMultipartSessionAuthRequiredError ← 403 multipart resume: anon session
│ └─ GislAuthError ← 401/403 + AuthErrorType discriminator
├─ GislMultipartPartError ← an S3 part PUT failed terminally after retries
├─ GislMultipartPartCountError ← upload exceeds the S3 10 000-part ceiling
├─ GislTimeoutError ← request aborted by the client-side timeout
└─ GislAbortError ← request aborted by a caller-supplied AbortSignal

Not wrapped by the SDK: low-level network failures from fetch (DNS failures, ECONNREFUSED, TLS errors) surface as whatever the runtime throws — typically a TypeError. Treat catch branches as "one of the Gisl* classes or a raw network error", and either narrow with instanceof or let unexpected errors bubble.

GislApiError — what a failed request gives you

GislApiError is thrown for any non-2xx response (or a {success: false} envelope), including non-JSON / invalid-JSON error bodies. Read the machine code for control flow, never the human message.

FieldMeaning
statusCodeHTTP status from the response.
errorMessageThe human-readable message (the server's message, or its error field when message is absent). For display — branch on errorCode (or a typed subclass), not this string.
errorCodeThe wire-stable machine code — the envelope's error field (SCREAMING_SNAKE, never localised). Branch on THIS (PHP parity: $e->errorCode). undefined when the wire carries no error (non-JSON / invalid-JSON).
pathThe request path, e.g. /api/workflows/abc/status.
detailsAdditional error-envelope payload (opaque — narrow with GislValidationError).
messageKeyStable, never-localised i18n lookup key, e.g. error.balance_exhausted.add_credits.
messageParamsInterpolation values for the localised message. Excludes monetary numbers.
payloadThe full response envelope (typed on each structured subclass below).
responseHeadersResponse headers as a Record<string, string> with lowercased keys — e.g. err.responseHeaders['x-request-id'].
contentLanguageThe Content-Language response header — the language the server actually resolved.

The message is formatted API error <statusCode> at <path>: <errorMessage> (the at <path> part is dropped when path is absent).

GislValidationError

Thrown when the server returns a details array of validation-detail objects (typically status 400). Unlike GislApiError's opaque details, this subclass narrows the type — no type-guards needed. Each detail carries a message; field is optional (a cross-field or per-option error may carry operation / option instead), so guard it (detail.field ?? detail.option) when rendering.

Structured error subclasses

The contracts ship discriminator-tagged response envelopes for the most actionable states. The SDK throws a typed subclass for each and narrows the payload (TypeScript) / exposes a typed ->typedPayload (PHP) so you can drive top-up / upgrade / retry UI without parsing raw bodies.

ClassHTTPDiscriminatorTyped payload
GislBalanceExhaustedError402balance_exhaustedBalanceExhaustedResponse (requiredAction, links)
GislTierRestrictedError403tier_restrictionTierRestrictionResponse (restrictionKind, currentTier, requiredTier?)
GislFeatureTierRestrictedError403feature_tier_restrictedFeatureTierRestrictedResponse (violations[])
GislFeatureNotAvailableError422feature_not_availableFeatureNotAvailableResponse (violations[])
GislWorkflowExpiredError422workflow_expiredWorkflowExpiredResponse (expiredAt)
GislAuthError401 / 403one of 8 AuthErrorType valuesAuthErrorResponse (errorType)

Notes that bite:

  • GislBalanceExhaustedError envelopes carry no numeric balance — that is a deliberate contract narrowing. React to the requiredAction enum (add_credits / upgrade_plan / wait_for_renewal) and fetch numeric state from the dedicated credits endpoint only when the UI needs to show numbers.
  • GislUploadCapExceededError — per-tier size/duration caps are server-authoritative. There is no read-ahead cap API; do not preflight off operation metadata (it reports only the conservative baseline floor). Let the upload proceed and handle this error for the real limit; read .kind (size | duration) for the cap class.
  • GislAuthError covers all 8 AuthErrorType discriminators — branch on the typed errorType to drive login / re-auth UI without status-code sniffing. In PHP, ->typedPayload is nullable (a generic 401 has none) — guard if ($e->typedPayload !== null).

Top-up flow example

import { GislBalanceExhaustedError } from '@giveitsmaller/sdk';

try {
await client.createWorkflow(payload);
} catch (e) {
if (e instanceof GislBalanceExhaustedError) {
switch (e.payload.requiredAction) {
case 'add_credits':
showTopUpDialog({ links: e.payload.links });
break;
case 'upgrade_plan':
showUpgradeDialog({ links: e.payload.links });
break;
case 'wait_for_renewal':
showRenewalNotice({ messageKey: e.messageKey });
break;
}
return;
}
throw e;
}

File-first run failures (stored, NOT thrown)

client.file(...).run() / client.files(...).run() (and the handle's wait()) do not throw on a terminal failure. Each failed input lands in RunResult.failed[] (TypeScript) / RunResult::$failed (PHP) as an ItemFailure whose .error is a typed GislItemFailedError carrying:

  • .state — the terminal state (failed / expired / cancelled / partially_failed / paused_insufficient_credits, or a per-job non-completed status).
  • .errorMessage / .errorCode — read from the first failing operation. Both are absent (null in PHP) for cancel / expire / credit-pause — those carry only the bare .state.

Branch on failure.error.state / failure.error.errorCode instead of parsing the message string. The result's toJSON() (TypeScript) / toArray() (PHP) projects each entry as { key, error, state, errorMessage?, errorCode? }, with the two optional keys omitted when absent.

Downloading a result throws, though. Fetching an output URL after a successful run (toFile() / downloadTo()) is a plain HTTP GET, so a non-2xx response is thrown, not stored — as GislNetworkError in both SDKs, with the HTTP status baked into the message, e.g. "Download failed with status 404". The status lives only in the message (there is no dedicated status field); PHP surfaces it the same way TypeScript already does.

Retry guidance

When narrowing with instanceof / catch, always check the specific subclass first — GislValidationError before GislApiError — because the specific ones extend the general ones, so order matters.

ErrorRetry?Notes
GislTimeoutErrorRecover, don't blindly retryIf err.workflowId is set the work is still running server-side — poll it (see below) instead of re-running. If it's absent the SDK can't auto-recover, but that doesn't prove nothing was created — reconcile before re-running.
GislNetworkError (PHP) / raw fetch errorYesTransient transport.
GislApiError with statusCode >= 500YesExponential, max 3 attempts.
GislApiError with statusCode === 429Yes, after delayPrefer responseHeaders['retry-after'] (lowercased key); else exponential.
GislApiError with statusCode === 408YesAs for 5xx.
GislApiError, other 4xxNoFix the request.
GislValidationErrorNoFix the payload per details[].
GislConfigError / merge subclasses (PHP)NoCaller bug — fix the call.
GislAbortError (TS)NoCaller cancelled.
GislError from webhook verifyNoReject the delivery.

A minimal retry wrapper:

import { GislApiError, GislTimeoutError } from '@giveitsmaller/sdk';

async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 3): Promise<T> {
let lastErr: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
// NOTE: GislTimeoutError is deliberately NOT retried here. A timed-out
// run()/wait() usually SUCCEEDED server-side — blindly re-running would
// re-upload and double-charge. Recover it via `err.workflowId` instead
// (see "Recovering a timed-out run()" above); a transport timeout on an
// idempotent read can be retried, but that decision is caller-specific.
const retryable =
err instanceof GislApiError &&
(err.statusCode >= 500 ||
err.statusCode === 429 ||
err.statusCode === 408);
if (!retryable || attempt === maxAttempts) throw err;
await new Promise((r) => setTimeout(r, 1000 * 2 ** (attempt - 1)));
}
}
throw lastErr;
}

Recovering a timed-out run()

A GislTimeoutError from run() / wait() almost never means the work failed — maxWait is a client-side deadline, so the server keeps processing after it elapses. Re-running is the wrong reflex: it re-uploads the input and creates a second workflow, which (for a signed-in caller) settles a second charge for a deliverable the first run already produced.

Instead, when the error carries a workflowId, poll that workflow to recover the result. An absent workflowId means the SDK has no id to recover with — it does not guarantee that nothing was created or charged. It covers both the safe case (an upload / probe timeout before any workflow existed) and the ambiguous case (the create request itself timed out — the server may have created and charged the workflow before its response was lost). So treat an absent id as "cannot auto-recover", not "clean slate": reconcile (e.g. list your recent workflows) before starting over, rather than assuming nothing happened.

import { GislTimeoutError } from '@giveitsmaller/sdk';

try {
const result = await client.file('big.mp4').compress().run({ maxWait: '10m' });
await result.toFile('out.mp4');
} catch (err) {
if (err instanceof GislTimeoutError && err.workflowId) {
// The work is still running — wait on the SAME workflow, don't re-run.
const status = await client.waitForWorkflow(err.workflowId, { timeoutMs: 300_000 });
if (status.status === 'completed') {
const downloads = await client.getWorkflowDownloads(err.workflowId);
// ... fetch downloads[].url
} else {
// Any other terminal state — failed / partially_failed / cancelled /
// expired / paused_insufficient_credits — must be handled, not ignored.
throw new Error(`Workflow ${err.workflowId} ended as ${status.status}`);
}
} else {
throw err; // no workflowId → cannot auto-recover; reconcile before re-running
}
}

Retry & rate-limit metadata

GislApiError exposes four read-only accessors that fold the retry decision and the server's own back-off hints into first-class fields. They are read accessors — the SDK does not retry for you; use them to drive your own back-off. category lets you skip retries that can never succeed (auth / validation) while still retrying transient classes (network).

AccessorTypeMeaning
retryablebooleantrue when the HTTP status is retryable (408, 429, or 500–599) or the resolved ERROR_CODES registry entry marks the code retryable.
categoryErrorCategory | undefinedTaxonomy category from the generated registry — 'api' | 'config' | 'network' | 'auth' | 'validation' | 'chain'. undefined when the code isn't in the registry. ErrorCategory is exported (type-only) from the package root.
rateLimit{ limit: number; remaining: number; resetSeconds: number } | undefinedParsed from the X-RateLimit-Limit / -Remaining / -Reset headers — present only when all three are. resetSeconds is seconds-to-reset.
retryAfterSecondsnumber | undefinedWhole seconds from the Retry-After header (RFC 9110 delta-seconds or HTTP-date). undefined when absent, zero, in the past, or malformed.
import { GislApiError } from '@giveitsmaller/sdk';

async function withServerBackoff<T>(fn: () => Promise<T>, maxAttempts = 3): Promise<T> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
const fatal =
err instanceof GislApiError &&
(err.category === 'auth' || err.category === 'validation');
if (!(err instanceof GislApiError) || !err.retryable || fatal || attempt === maxAttempts) {
throw err;
}
// Prefer the server's own hint; fall back to exponential back-off.
const serverDelaySeconds = err.retryAfterSeconds ?? err.rateLimit?.resetSeconds;
const delayMs =
serverDelaySeconds !== undefined ? serverDelaySeconds * 1000 : 1000 * 2 ** (attempt - 1);
await new Promise((r) => setTimeout(r, delayMs));
}
}
throw new Error('unreachable');
}

Status code → meaning

StatusMeaning (as GISL uses it)
400Validation error — inspect GislValidationError details.
401Missing or invalid API key.
403Key is valid but lacks permission (or a tier restriction).
404Resource (workflow, upload, operation) not found — check the id.
408Server-side request timeout (rare) — retry.
413Payload too large — file exceeds plan/upload limits.
422Well-formed but semantically incorrect (e.g. unsupported operation on a MIME group).
429Rate-limited — honour retry-after when present, else back off.
5xxServer error — retry with exponential backoff.

See also