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
- TypeScript
- PHP
- Python
- Rust
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.
Every error extends Gisl\Sdk\Errors\GislError (which extends
\RuntimeException), so catch (\Gisl\Sdk\Errors\GislError $e) is a safe
backstop.
GislError (base; also thrown by Webhook::verify)
├─ GislApiError (non-2xx / {success:false})
│ ├─ GislValidationError (4xx with a details[] list)
│ ├─ GislAuthError (401 / 403)
│ ├─ GislBalanceExhaustedError (402 — out of credits)
│ ├─ GislTierRestrictedError (403 — plan tier too low)
│ ├─ GislFeatureTierRestrictedError (403 — feature needs a higher tier)
│ ├─ GislFeatureNotAvailableError (422 — feature not available)
│ ├─ GislWorkflowExpiredError (422 — workflow result expired)
│ ├─ GislUploadCapExceededError (422 / 413 — file too large for tier)
│ └─ GislMultipartSession*Error (404 / 403 — multipart resume)
├─ GislConfigError (client-side input validation, before the wire)
│ ├─ GislMissingCredentialsError (no API key and not in cookie mode)
│ ├─ 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)
├─ GislNetworkError (PSR-18 transport failure — incl. request timeout)
├─ GislTimeoutError (an SDK deadline elapsed: waitForWorkflow / maxWait)
├─ GislMultipartPartError (a single multipart part failed)
└─ GislMultipartPartCountError (file needs more parts than the cap allows)
GislConfigError and its subclasses are raised locally, before any request
— fixing the call, not retrying, is the resolution. A transport-level timeout on
a single HTTP request surfaces as GislNetworkError; the PHP SDK has no
AbortSignal, so the only cancellation mechanism is the wall-clock
maxWait / timeout deadline.
Coming soon — the Python SDK docs land with its content ticket.
Coming soon — the Rust SDK docs land with its content ticket.
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.
- TypeScript
- PHP
- Python
- Rust
| Field | Meaning |
|---|---|
statusCode | HTTP status from the response. |
errorMessage | The 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. |
errorCode | The 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). |
path | The request path, e.g. /api/workflows/abc/status. |
details | Additional error-envelope payload (opaque — narrow with GislValidationError). |
messageKey | Stable, never-localised i18n lookup key, e.g. error.balance_exhausted.add_credits. |
messageParams | Interpolation values for the localised message. Excludes monetary numbers. |
payload | The full response envelope (typed on each structured subclass below). |
responseHeaders | Response headers as a Record<string, string> with lowercased keys — e.g. err.responseHeaders['x-request-id']. |
contentLanguage | The 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).
} catch (\Gisl\Sdk\Errors\GislApiError $e) {
$e->getMessage(); // human-readable message (locale-varying — do NOT branch on it)
$e->statusCode; // int HTTP status
$e->errorCode; // stable machine code, e.g. 'balance_exhausted' — branch on THIS
$e->payload; // the raw decoded response body (array)
$e->responseHeaders; // array<string,string>, keys lower-cased (e.g. 'retry-after')
$e->contentLanguage; // the response Content-Language, if any
}
PHP differs from TypeScript here. There is no
$e->errorMessageor$e->path. Use$e->getMessage()for the human string and$e->errorCodefor control flow. The i18n triple is also exposed ($e->messageKey,$e->messageParams,$e->locale) for callers that re-localise.
Coming soon — the Python SDK docs land with its content ticket.
Coming soon — the Rust SDK docs land with its content ticket.
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.
| Class | HTTP | Discriminator | Typed payload |
|---|---|---|---|
GislBalanceExhaustedError | 402 | balance_exhausted | BalanceExhaustedResponse (requiredAction, links) |
GislTierRestrictedError | 403 | tier_restriction | TierRestrictionResponse (restrictionKind, currentTier, requiredTier?) |
GislFeatureTierRestrictedError | 403 | feature_tier_restricted | FeatureTierRestrictedResponse (violations[]) |
GislFeatureNotAvailableError | 422 | feature_not_available | FeatureNotAvailableResponse (violations[]) |
GislWorkflowExpiredError | 422 | workflow_expired | WorkflowExpiredResponse (expiredAt) |
GislAuthError | 401 / 403 | one of 8 AuthErrorType values | AuthErrorResponse (errorType) |
Notes that bite:
GislBalanceExhaustedErrorenvelopes carry no numeric balance — that is a deliberate contract narrowing. React to therequiredActionenum (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.GislAuthErrorcovers all 8AuthErrorTypediscriminators — branch on the typederrorTypeto drive login / re-auth UI without status-code sniffing. In PHP,->typedPayloadis nullable (a generic 401 has none) — guardif ($e->typedPayload !== null).
Top-up flow example
- TypeScript
- PHP
- Python
- Rust
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;
}
} catch (\Gisl\Sdk\Errors\GislBalanceExhaustedError $e) {
$action = $e->typedPayload->getRequiredAction();
$links = $e->typedPayload->getLinks(); // e.g. a top-up URL
}
Coming soon — the Python SDK docs land with its content ticket.
Coming soon — the Rust SDK docs land with its content ticket.
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-completedstatus)..errorMessage/.errorCode— read from the first failing operation. Both are absent (nullin 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 — asGislNetworkErrorin 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 —GislValidationErrorbeforeGislApiError— because the specific ones extend the general ones, so order matters.
| Error | Retry? | Notes |
|---|---|---|
GislTimeoutError | Recover, don't blindly retry | If 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 error | Yes | Transient transport. |
GislApiError with statusCode >= 500 | Yes | Exponential, max 3 attempts. |
GislApiError with statusCode === 429 | Yes, after delay | Prefer responseHeaders['retry-after'] (lowercased key); else exponential. |
GislApiError with statusCode === 408 | Yes | As for 5xx. |
GislApiError, other 4xx | No | Fix the request. |
GislValidationError | No | Fix the payload per details[]. |
GislConfigError / merge subclasses (PHP) | No | Caller bug — fix the call. |
GislAbortError (TS) | No | Caller cancelled. |
GislError from webhook verify | No | Reject the delivery. |
A minimal retry wrapper:
- TypeScript
- PHP
- Python
- Rust
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;
}
try {
$result = $client->compress('./big.mp4', ['quality' => 75])
->run(new RunOptions(maxWait: '10m'));
} catch (\Gisl\Sdk\Errors\GislBalanceExhaustedError $e) {
// out of credits — surface the top-up link
} catch (\Gisl\Sdk\Errors\GislUploadCapExceededError $e) {
// file too large for this tier — $e->kind tells you which cap
} catch (\Gisl\Sdk\Errors\GislTimeoutError $e) {
// if $e->workflowId is set, RECOVER it (see below) — do NOT re-run
} catch (\Gisl\Sdk\Errors\GislNetworkError $e) {
// transient transport — safe to retry with backoff
} catch (\Gisl\Sdk\Errors\GislApiError $e) {
if ($e->statusCode >= 500 || $e->statusCode === 429) { /* retry */ }
else { throw $e; }
}
Coming soon — the Python SDK docs land with its content ticket.
Coming soon — the Rust SDK docs land with its content ticket.
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.
- TypeScript
- PHP
- Python
- Rust
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
}
}
use Gisl\Sdk\Errors\GislTimeoutError;
try {
$client->compress('big.mp4')->run(new RunOptions(maxWait: '10m'))->toFile('out.mp4');
} catch (GislTimeoutError $e) {
if ($e->workflowId !== null) {
// The work is still running — wait on the SAME workflow, don't re-run.
$status = $client->waitForWorkflow($e->workflowId, new WaitOptions(timeoutMs: 300_000));
if ($status->getStatus() === 'completed') {
$downloads = $client->getWorkflowDownloads($e->workflowId);
// ... fetch $downloads[]->getUrl()
} else {
// Any other terminal state — failed / partially_failed / cancelled /
// expired / paused_insufficient_credits — must be handled, not ignored.
throw new \RuntimeException("Workflow {$e->workflowId} ended as {$status->getStatus()}");
}
} else {
throw $e; // no workflowId → cannot auto-recover; reconcile before re-running
}
}
Coming soon — the Python SDK docs land with its content ticket.
Coming soon — the Rust SDK docs land with its content ticket.
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).
- TypeScript
- PHP
- Python
- Rust
| Accessor | Type | Meaning |
|---|---|---|
retryable | boolean | true when the HTTP status is retryable (408, 429, or 500–599) or the resolved ERROR_CODES registry entry marks the code retryable. |
category | ErrorCategory | undefined | Taxonomy 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 } | undefined | Parsed from the X-RateLimit-Limit / -Remaining / -Reset headers — present only when all three are. resetSeconds is seconds-to-reset. |
retryAfterSeconds | number | undefined | Whole 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');
}
| Accessor | Type | Meaning |
|---|---|---|
retryable() | bool | true when the HTTP status is retryable (408, 429, or 500–599) or the resolved registry entry marks the code retryable. |
category() | ?ErrorCategory | Backed enum Gisl\Sdk\Generated\SdkSpec\ErrorCategory (Api / Config / Network / Auth / Validation / Chain; ->value for the wire string). null when the code isn't in the registry. |
rateLimit() | ?array{limit: int, remaining: int, resetSeconds: int} | Parsed from the X-RateLimit-Limit / -Remaining / -Reset headers — present only when all three are. resetSeconds is seconds-to-reset. |
retryAfterSeconds() | ?int | Whole seconds from the Retry-After header (RFC 9110 delta-seconds or HTTP-date). null when absent, zero, in the past, or malformed. |
use Gisl\Sdk\Errors\GislApiError;
use Gisl\Sdk\Generated\SdkSpec\ErrorCategory;
function withServerBackoff(callable $fn, int $maxAttempts = 3): mixed
{
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
try {
return $fn();
} catch (GislApiError $e) {
$fatal = $e->category() === ErrorCategory::Auth
|| $e->category() === ErrorCategory::Validation;
if (!$e->retryable() || $fatal || $attempt === $maxAttempts) {
throw $e;
}
// Prefer the server's own hint; fall back to exponential back-off.
$rateLimit = $e->rateLimit();
$delaySeconds = $e->retryAfterSeconds()
?? ($rateLimit !== null ? $rateLimit['resetSeconds'] : null)
?? 2 ** ($attempt - 1);
sleep($delaySeconds);
}
}
}
Coming soon — the Python SDK docs land with its content ticket.
Coming soon — the Rust SDK docs land with its content ticket.
Status code → meaning
| Status | Meaning (as GISL uses it) |
|---|---|
400 | Validation error — inspect GislValidationError details. |
401 | Missing or invalid API key. |
403 | Key is valid but lacks permission (or a tier restriction). |
404 | Resource (workflow, upload, operation) not found — check the id. |
408 | Server-side request timeout (rare) — retry. |
413 | Payload too large — file exceeds plan/upload limits. |
422 | Well-formed but semantically incorrect (e.g. unsupported operation on a MIME group). |
429 | Rate-limited — honour retry-after when present, else back off. |
5xx | Server error — retry with exponential backoff. |
See also
- Progress streaming (SSE) — errors thrown while opening a stream.
- Changelog — what changed between SDK versions.