Skip to main content

Errors

The SDK throws a tree of Gisl* error classes for the conditions it recognises. Every one of them is instance-checkable (instanceof in TypeScript, instanceof / catch (...) in PHP). The PHP SDK mirrors the TypeScript surface class-for-class. The classes really do match — including GislAbortError, GislTransportError and GislDownloadHttpError, all of which exist in both.

What differs is the cancellation mechanism, not the taxonomy: TypeScript cancels with a standard AbortSignal, PHP with a Cancellation token, and each raises its own GislAbortError. Port the intent, not the argument type — see Cancellation.

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
│ ├─ GislUnsupportedFileTypeError ← 415 upload: a file type NO tier can process
│ ├─ GislAuthError ← 401/403 + AuthErrorType discriminator
│ ├─ GislAuthRejectionError ← 422 on an auth side-effect route (register / verify-email / api-keys / email-unchanged)
│ ├─ GislLongFormConcurrencyError ← 429 long-form concurrency cap reached
│ └─ GislProbePendingError ← the upload probe has not landed yet
├─ GislConfigError ← local, raised BEFORE any request
│ ├─ GislMissingCredentialsError ← no API key and not in cookie mode
│ ├─ GislFeatureRequiresAuthError ← the feature needs a signed-in caller (raised LOCALLY)
│ ├─ GislStreamHostNotDeclaredError ← streamEvents() with no declared stream host
│ ├─ GislUndeclaredAssetError ← merge: a sequenced asset was never declared
│ ├─ GislUnusedAssetError ← merge: a declared asset was never sequenced
│ ├─ GislPerInputOptionsNotSupportedError ← merge: per-input options on an image merge
│ ├─ GislChainCardinalityMismatchError ← declared, not raised today (chain on a multi-output parent)
│ └─ GislBundleAlreadyArchivedError ← declared, not raised today (.bundle() on an archive)
├─ GislNetworkError ← off-envelope failure; NEVER thrown directly
│ ├─ GislTransportError ← the request failed in transit (DNS, TLS, reset)
│ ├─ GislRequestNotSentError ← never left the client; never retryable
│ └─ GislDownloadHttpError ← an output URL answered non-2xx (.status)
├─ GislSinkError ← a file-first sink could not deliver; read `.reason`
│ `.reason`: not_single_output · downloader_unavailable · partial_failure
│ duplicate_filename · invalid_directory · write_failed
├─ GislNoSuchKeyError ← downloadTo()/toFile() named a key with no output
├─ GislItemFailedError ← a terminal per-item failure inside RunResult.failed[]
├─ GislResultNotReadyError ← Handle.result() before the workflow is terminal
├─ 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
│ └─ GislFanOutTimeoutError ← mapEach timed out mid-batch; carries the completed ids
└─ GislAbortError ← request aborted by a caller-supplied AbortSignal
GislAuthRejectionError — exported, and not yet reachable

Both SDKs raise it for a 422 carrying the flat AuthRejectionEnvelope — the auth side-effect routes (register, verify-email, api-keys, profile email-unchanged) — and both export it.

🔴 But no public SDK method can raise it today, and this page is not going to pretend otherwise. The 422 AuthRejectionEnvelope belongs to register, verify-email, api-keys and the profile PATCH — none of which has a client method. login() is the only auth route the SDK exposes, and it is specified to return 401 invalid_credentials, never this envelope.

⇒ So what IS true: the mapping lives in the shared response handler, so the class is raised for any request whose 422 carries that envelope, and it is exported now so the day those methods land needs no SDK release. Narrowing looks like this, wherever you meet it:

import { GislAuthRejectionError, GislValidationError } from '@giveitsmaller/sdk';

function classify(err: unknown) {
if (err instanceof GislAuthRejectionError) {
return 'domain rejection'; // e.g. "this email is already registered"
}
if (err instanceof GislValidationError) {
return 'malformed payload'; // the other branch of the same 422 oneOf
}
throw err;
}

If you need the discriminator itself (unprocessable_entity / email_same), its spelling differs per language, because each generator follows its own ecosystem:

  • TypeScript: err.payload.errorType — camelCase, as the generated model emits it.
  • PHP: $e->getErrorType() — a getter on the error itself.

⚠️ The wire field is error_type; neither SDK hands you the raw key, so do not reach for that spelling in either language.

⚠️ GislValidationError is the OTHER branch of the same 422 oneOf. Catch the one you mean — a bare GislApiError with a statusCode === 422 test cannot tell them apart, which is the unwrapping these two classes exist to spare you.

This list is checked against the SDK, not maintained by hand

Every class above appears in packages/typescript/tests/api-surface/index.txt — the committed snapshot of the public surface that CI computes from source — with no exceptions.

If you find a Gisl*Error the SDK throws and this page does not list, that is reportable drift — not a class you should handle differently.

Not wrapped by the SDK: low-level network failures from fetch on an API call (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. Result downloads are the exception: a download that fails in transit is wrapped as GislTransportError, and one the URL answers with a non-2xx status as GislDownloadHttpError.

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)
GislLongFormConcurrencyError429code LONG_FORM_CONCURRENCY_LIMIT_EXCEEDEDLongFormConcurrencyLimitResponse (links.upgrade)
GislUnsupportedFileTypeError415none — status only; errorCode is UNSUPPORTED_FILE_TYPEplain envelope

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 — invalid_credentials, account_locked, account_disabled, account_deleted, account_deletion_expired, authentication_required, reauthentication_required, api_key_invalid. 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).
  • Three "you can't do that" errors, three different fixes. GislTierRestrictedError means a higher tier would allow the request: read restrictionKind, currentTier and requiredTier for the upgrade prompt. GislFeatureTierRestrictedError lists each gated feature in violations[]. GislUnsupportedFileTypeError (415, raised on upload) means no tier can process the file type, so an upgrade prompt would be wrong.
  • GislFeatureNotAvailableError means the feature exists in the contract but is not available yet; violations[] names which ones the request hit.
  • GislLongFormConcurrencyError is a 429 that is not a rate limit: it carries no Retry-After and clears only when one of your in-flight long-form workflows finishes. Its retryable is always false, so a back-off loop driven by retryable will not spin on it. Wait for a running workflow, or send the user to the upgrade link (upgradeUrl in TypeScript, upgradeUrl() in PHP). Any other 429 is a plain GislApiError.

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;
}

Configuration and merge errors​

GislConfigError and its subclasses are raised before any upload or request, so nothing is spent. Every one may carry reason (a machine code), conflictingFields, resolvedSnapshot (what would have been sent) and a human suggestion — read those instead of the message. None is retryable.

The merge builder raises three subclasses with structured data, the same field names in both SDKs:

ClassWhenDataFix
GislUndeclaredAssetErrorsequence() references an asset that was not passed to merge(...)assetId, declaredAssetsdeclare it, or drop the reference
GislUnusedAssetErrorsequence() is set but a declared asset is never referencedunusedAssetsreference it, remove it, or set allowUnusedAssets: true on the merge options
GislPerInputOptionsNotSupportedErrorper-input options on a merge kind that has none (image)mediaKindset transition at the merge level instead

GislStreamHostNotDeclaredError is the other one you are likely to meet: see the stream is on a second host.

Language negotiation​

Set locale on the client and it is sent as Accept-Language on every request. The language the server actually used comes back on the error as contentLanguage (the Content-Language header), which may differ from what you asked for. It is distinct from locale on the error, which is the tag in the response body.

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

const client = await gisl.create({ apiKey: 'REPLACE_ME_API_KEY', locale: 'fr-FR' });

try {
await client.createWorkflow(payload);
} catch (err) {
if (err instanceof GislApiError) {
console.log('served language:', err.contentLanguage);
console.log('request id:', err.responseHeaders?.['x-request-id']);
}
throw err;
}

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 failure is thrown, not stored. A non-2xx response is GislDownloadHttpError in both SDKs, with the HTTP status on its status field — read the field, not the message. A download that fails in transit is GislTransportError. Both extend GislNetworkError, which is never thrown directly, so catch (GislNetworkError) still catches either. retryable on GislDownloadHttpError follows the same 408 / 429 / 5xx rule as the API errors, so a 404 reports false.

Cancellation​

TypeScript cancels with an AbortSignal; PHP, which has none, with a Cancellation token. Either way the call ends in GislAbortError, which is never retryable: the caller asked to stop.

Pass signal to run() (and to uploadFile(), waitForProbe() and the other calls that accept one). Aborting cancels the in-flight request. When the signal and the client's per-request timeout could both fire, the first to fire decides the class: your abort gives GislAbortError, the timer GislTimeoutError.

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

const controller = new AbortController();
setTimeout(() => controller.abort(), 60_000);

await client.file('big.mp4').compress(OptimizeFor.Balanced)
.run({ maxWait: '10m', signal: controller.signal });

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.
GislTransportError / raw fetch error (TypeScript API calls)YesTransient transport.
GislRequestNotSentErrorNoThe request could not be sent; re-sending fails the same way.
GislDownloadHttpErrorPer retryableYes for 408 / 429 / 5xx; a 404 will not recover.
GislLongFormConcurrencyErrorNoWait for a running long-form workflow to finish, or upgrade.
GislApiError with statusCode >= 500YesExponential, max 3 attempts.
GislApiError with statusCode === 429Yes, after delay (never GislLongFormConcurrencyError, whose retryable is false)Prefer responseHeaders['retry-after'] (lowercased key); else exponential.
GislApiError with statusCode === 408YesAs for 5xx.
GislApiError, other 4xxNoFix the request.
GislValidationErrorNoFix the payload per details[].
GislConfigError and subclassesNoCaller bug — fix the call.
GislAbortErrorNoCaller 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.
// `retryable` covers 5xx/429/408 and excludes the classes that must not
// be retried (e.g. the long-form concurrency 429).
const retryable = err instanceof GislApiError && err.retryable;
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);
// ... each downloads.downloads[].files[].downloadUrl
} 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.
415Unsupported file type — no tier can process it (GislUnsupportedFileTypeError).
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​