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
- 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
│ ├─ 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 reachableBoth 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.
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.
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)
│ ├─ 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 — over the tier cap; ->kind)
│ ├─ GislMultipartSessionNotFoundError (404 — multipart resume: session gone)
│ ├─ GislMultipartSessionOwnershipError (403 — multipart resume: another user's)
│ ├─ GislMultipartSessionAuthRequiredError (403 — multipart resume: anon session)
│ ├─ GislUnsupportedFileTypeError (415 — a file type NO tier can process)
│ ├─ GislAuthError (401 / 403)
│ ├─ GislAuthRejectionError (422 on an auth side-effect route)
│ ├─ GislLongFormConcurrencyError (429 — long-form concurrency cap reached)
│ └─ GislProbePendingError (the upload probe has not landed yet)
├─ GislConfigError (client-side input validation, before the wire)
│ ├─ GislMissingCredentialsError (no API key and not in cookie mode)
│ ├─ GislFeatureRequiresAuthError (the feature needs a signed-in caller)
│ ├─ 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)
│ └─ GislBundleAlreadyArchivedError (declared, not raised today)
├─ GislNetworkError (off-envelope failure; never thrown directly)
│ ├─ GislTransportError (PSR-18 network failure — DNS, TLS, reset, client timeout)
│ ├─ GislRequestNotSentError (the PSR-18 client refused to send it; never retryable)
│ └─ GislDownloadHttpError (an output URL answered non-2xx; ->status)
├─ GislSinkError (a file-first sink could not deliver; read ->reason)
├─ GislNoSuchKeyError (downloadTo()/toFile() named a key with no output)
├─ GislItemFailedError (a terminal per-item failure inside RunResult::$failed)
├─ GislResultNotReadyError (Handle result read before the workflow is terminal)
├─ GislMultipartPartError (a multipart part failed; ->partNumber, ->uploadId)
├─ GislMultipartPartCountError (needs more parts than the cap; ->requiredParts, ->maxParts)
├─ GislTimeoutError (an SDK deadline elapsed: waitForWorkflow / maxWait)
│ └─ GislFanOutTimeoutError (a fan-out timed out mid-batch; carries the completed ids)
└─ GislAbortError (a Cancellation token was cancelled)
GislConfigError and its subclasses are raised locally, before any request
— fixing the call, not retrying, is the resolution. A timeout on a single HTTP
request is enforced by your PSR-18 client, not the SDK (the configured
timeoutMs is advisory), and surfaces as a GislNetworkError subclass —
normally GislTransportError. GislTimeoutError
is reserved for the SDK's own deadlines.
Not available: the Python SDK is not published and no release is scheduled — see Python.
Not an application SDK: Rust is internal contract types — see Rust.
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.
Not available: the Python SDK is not published and no release is scheduled — see Python.
Not an application SDK: Rust is internal contract types — see Rust.
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) |
GislLongFormConcurrencyError | 429 | code LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED | LongFormConcurrencyLimitResponse (links.upgrade) |
GislUnsupportedFileTypeError | 415 | none — status only; errorCode is UNSUPPORTED_FILE_TYPE | plain envelope |
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 —invalid_credentials,account_locked,account_disabled,account_deleted,account_deletion_expired,authentication_required,reauthentication_required,api_key_invalid. 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).- Three "you can't do that" errors, three different fixes.
GislTierRestrictedErrormeans a higher tier would allow the request: readrestrictionKind,currentTierandrequiredTierfor the upgrade prompt.GislFeatureTierRestrictedErrorlists each gated feature inviolations[].GislUnsupportedFileTypeError(415, raised on upload) means no tier can process the file type, so an upgrade prompt would be wrong. GislFeatureNotAvailableErrormeans the feature exists in the contract but is not available yet;violations[]names which ones the request hit.GislLongFormConcurrencyErroris a 429 that is not a rate limit: it carries noRetry-Afterand clears only when one of your in-flight long-form workflows finishes. Itsretryableis alwaysfalse, so a back-off loop driven byretryablewill not spin on it. Wait for a running workflow, or send the user to the upgrade link (upgradeUrlin TypeScript,upgradeUrl()in PHP). Any other 429 is a plainGislApiError.
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
}
Not available: the Python SDK is not published and no release is scheduled — see Python.
Not an application SDK: Rust is internal contract types — see Rust.
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:
| Class | When | Data | Fix |
|---|---|---|---|
GislUndeclaredAssetError | sequence() references an asset that was not passed to merge(...) | assetId, declaredAssets | declare it, or drop the reference |
GislUnusedAssetError | sequence() is set but a declared asset is never referenced | unusedAssets | reference it, remove it, or set allowUnusedAssets: true on the merge options |
GislPerInputOptionsNotSupportedError | per-input options on a merge kind that has none (image) | mediaKind | set 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.
- TypeScript
- PHP
- Python
- Rust
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;
}
use Gisl\Sdk\Gisl;
use Gisl\Sdk\Errors\GislApiError;
$client = Gisl::create(apiKey: 'sk_...', locale: 'fr-FR');
try {
$client->createWorkflow($payload);
} catch (GislApiError $e) {
echo 'served language: ' . $e->contentLanguage;
echo 'request id: ' . ($e->responseHeaders['x-request-id'] ?? '');
throw $e;
}
Not available: the Python SDK is not published and no release is scheduled — see Python.
Not an application SDK: Rust is internal contract types — see Rust.
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 failure is thrown, not stored. A non-2xx response isGislDownloadHttpErrorin both SDKs, with the HTTP status on itsstatusfield — read the field, not the message. A download that fails in transit isGislTransportError. Both extendGislNetworkError, which is never thrown directly, socatch (GislNetworkError)still catches either.retryableonGislDownloadHttpErrorfollows the same408/429/5xxrule as the API errors, so a404reportsfalse.
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.
- TypeScript
- PHP
- Python
- Rust
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 });
Pass a Cancellation to RunOptions(cancellation:), to the file-first
run(cancellation:), or to Handle::wait(), then call cancel() from elsewhere
(a signal handler, say). Cancellation is cooperative: the SDK checks the
token between steps — around each upload, before creating the workflow, and
between stream frames or polls. It does not interrupt an HTTP request already
in flight.
use Gisl\Sdk\Cancellation;
use Gisl\Sdk\Errors\GislAbortError;
use Gisl\Sdk\Generated\SdkSpec\Enums\OptimizeFor;
$cancel = new Cancellation();
pcntl_async_signals(true);
pcntl_signal(SIGINT, fn () => $cancel->cancel());
try {
$client->file('big.mp4')->compress(OptimizeFor::Balanced)
->run(maxWait: '10m', cancellation: $cancel);
} catch (GislAbortError $e) {
// cancelled by the caller — do not retry
}
Not available: the Python SDK is not published and no release is scheduled — see Python.
Not an application SDK: Rust is internal contract types — see Rust.
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. |
GislTransportError / raw fetch error (TypeScript API calls) | Yes | Transient transport. |
GislRequestNotSentError | No | The request could not be sent; re-sending fails the same way. |
GislDownloadHttpError | Per retryable | Yes for 408 / 429 / 5xx; a 404 will not recover. |
GislLongFormConcurrencyError | No | Wait for a running long-form workflow to finish, or upgrade. |
GislApiError with statusCode >= 500 | Yes | Exponential, max 3 attempts. |
GislApiError with statusCode === 429 | Yes, after delay (never GislLongFormConcurrencyError, whose retryable is false) | 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 and subclasses | No | Caller bug — fix the call. |
GislAbortError | 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.
// `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;
}
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\GislTransportError $e) {
// transient transport — safe to retry with backoff
} catch (\Gisl\Sdk\Errors\GislApiError $e) {
if ($e->retryable()) { /* retry with backoff (not the long-form concurrency 429) */ }
else { throw $e; }
}
Not available: the Python SDK is not published and no release is scheduled — see Python.
Not an application SDK: Rust is internal contract types — see Rust.
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);
// ... 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
}
}
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);
// ... each $downloads->getDownloads()[]->getFiles()[]->getDownloadUrl()
} 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
}
}
Not available: the Python SDK is not published and no release is scheduled — see Python.
Not an application SDK: Rust is internal contract types — see Rust.
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);
}
}
}
Not available: the Python SDK is not published and no release is scheduled — see Python.
Not an application SDK: Rust is internal contract types — see Rust.
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. |
415 | Unsupported file type — no tier can process it (GislUnsupportedFileTypeError). |
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.