---
title: "Errors, retries and idempotency"
description: "How the server SDKs classify failures, when they retry, and why every mutation carries an idempotency key."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.seatlayer.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors, retries and idempotency

Selling seats is a competition. Two buyers want the same seat, networks drop
mid-booking, and a retry that books twice is worse than one that fails. This page
covers what the SDKs do about that so you do not have to.

## Errors you branch on

A sold-out seat is a business outcome, not an exception to log and forget. The
SDKs type failures by what an integration actually does about them.

### Node.js

```ts
import {
  SeatLayerAuthError,
  SeatLayerConflictError,
  SeatLayerRateLimitError,
} from '@seatlayer/server';

try {
  await seatlayer.inventory.holdBestAvailable(eventKey, { qty: 6 });
} catch (error) {
  if (error instanceof SeatLayerConflictError && error.isSoldOut) {
    return showAlternativeDates();
  }
  if (error instanceof SeatLayerRateLimitError) {
    return retryAfter(error.retryAfterSeconds);
  }
  if (error instanceof SeatLayerAuthError && error.isModeMismatch) {
    throw new Error('Test key pointed at a live event, or the reverse.');
  }
  throw error;
}
```
### Python

```python
from seatlayer import (
    SeatLayerAuthError,
    SeatLayerConflictError,
    SeatLayerRateLimitError,
)

try:
    seatlayer.inventory.hold_best_available(event_key, qty=6)
except SeatLayerConflictError as error:
    if error.is_sold_out:
        return show_alternative_dates()
    raise
except SeatLayerRateLimitError as error:
    return retry_after(error.retry_after_seconds)
except SeatLayerAuthError as error:
    if error.is_mode_mismatch:
        raise RuntimeError("Test key pointed at a live event, or the reverse.") from error
    raise
```
### PHP

```php
use SeatLayer\AuthException;
use SeatLayer\ConflictException;
use SeatLayer\RateLimitException;

try {
    $seatlayer->inventory->holdBestAvailable($eventKey, qty: 6);
} catch (ConflictException $error) {
    if ($error->isSoldOut()) {
        return showAlternativeDates();
    }
    throw $error;
} catch (RateLimitException $error) {
    return retryAfter($error->retryAfterSeconds);
} catch (AuthException $error) {
    if ($error->isModeMismatch()) {
        throw new RuntimeException('Test key pointed at a live event, or the reverse.');
    }
    throw $error;
}
```
### Java

```java
try {
    seatlayer.inventory().holdBestAvailable(eventKey, 6);
} catch (SeatLayerConflictException e) {
    if (e.isSoldOut()) {
        return showAlternativeDates();
    }
    throw e;
} catch (SeatLayerRateLimitException e) {
    return retryAfter(e.retryAfterSeconds());
} catch (SeatLayerAuthException e) {
    if (e.isModeMismatch()) {
        throw new IllegalStateException("Test key pointed at a live event, or the reverse.");
    }
    throw e;
}
```
### Go

```go
_, err := client.Inventory.HoldBestAvailable(ctx, eventKey,
    seatlayer.BestAvailableParams{Qty: 6})

var conflict *seatlayer.ConflictError
var rateLimit *seatlayer.RateLimitError
var auth *seatlayer.AuthError

switch {
case errors.As(err, &conflict) && conflict.SoldOut():
    return offerAlternativeDates()          // a business outcome, not a bug
case errors.As(err, &rateLimit):
    return retryAfter(rateLimit.RetryAfter)
case errors.As(err, &auth) && auth.ModeMismatch():
    return errors.New("test key pointed at a live event, or the reverse")
case err != nil:
    return err
}
```
### Ruby

```ruby
begin
  client.inventory.hold_best_available(event_key, qty: 6)
rescue SeatLayer::ConflictError => e
  return show_alternative_dates if e.sold_out?   # a business outcome, not a bug
  raise
rescue SeatLayer::RateLimitError => e
  return retry_after(e.retry_after)
rescue SeatLayer::AuthError => e
  raise "Test key pointed at a live event, or the reverse." if e.mode_mismatch?
  raise
end
```
### .NET

```csharp
try
{
    await client.Inventory.HoldBestAvailableAsync(eventKey, new BestAvailableRequest { Qty = 6 });
}
catch (SeatLayerConflictException e) when (e.IsSoldOut)
{
    return OfferAlternativeDates();          // a business outcome, not a bug
}
catch (SeatLayerRateLimitException e)
{
    return RetryAfter(e.RetryAfterSeconds);
}
catch (SeatLayerAuthException e) when (e.IsModeMismatch)
{
    throw new InvalidOperationException("Test key pointed at a live event, or the reverse.");
}
```

| Class | Status | Means |
|---|---|---|
| `SeatLayerAuthError` | 401, 403 | Bad, revoked, or wrong-mode key |
| `SeatLayerNotFoundError` | 404 | No such resource *for this organisation* |
| `SeatLayerConflictError` | 409 | Inventory moved, or a guard rejected the change |
| `SeatLayerValidationError` | 422 | Understood and rejected |
| `SeatLayerRateLimitError` | 429 | Over budget; carries `retryAfterSeconds` |
| `SeatLayerConnectionError` | — | Never got an answer: DNS, TLS, socket, timeout |

Every API error carries `status`, `code`, `body`, and a `requestId` taken from
the `X-Request-ID` header. Quote that id in support requests.

> **PHP names the slug errorCode**
>
> In the PHP SDK the machine-readable slug is `$e->errorCode`, not `$e->code` —
> PHP's base `Exception` already owns `$code` as an int and a property cannot be
> redeclared. Same value, forced rename.

> **404 is deliberate on another org's resource**
>
> Asking for a resource that belongs to someone else answers `404`, never `403`.
> A `403` would confirm the thing exists, which is not something one customer
> should be able to learn about another.

## Retries

The SDKs retry `429`, `408`, and `5xx` with exponential backoff and full jitter,
and honour `Retry-After` when we send it. Jitter matters: a fleet of workers that
all get limited in the same second would otherwise retry in lockstep and
re-limit itself.

`4xx` responses are never retried. They will not start succeeding, and retrying
one only burns rate-limit budget and delays the error you need to see.

### Node.js

```ts
new SeatLayer({
  secretKey: process.env.SEATLAYER_SECRET_KEY!,
  maxRetries: 3,      // total attempts
  timeoutMs: 30_000,  // per attempt
});
```
### Python

```python
SeatLayer(
    os.environ["SEATLAYER_SECRET_KEY"],
    max_retries=3,   # total attempts
    timeout=30.0,    # seconds, per attempt
)
```
### PHP

```php
new SeatLayer(
    getenv('SEATLAYER_SECRET_KEY'),
    maxRetries: 3,   // total attempts
    timeout: 30.0,   // seconds, per attempt
);
```
### Java

```java
SeatLayer.builder()
    .secretKey(System.getenv("SEATLAYER_SECRET_KEY"))
    .maxRetries(3)                        // total attempts
    .timeout(Duration.ofSeconds(30))      // per attempt
    .build();
```
### Go

```go
client, err := seatlayer.New(
    os.Getenv("SEATLAYER_SECRET_KEY"),
    seatlayer.WithMaxRetries(3),                                  // total attempts
    seatlayer.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),  // per attempt
)
```
### Ruby

```ruby
SeatLayer::Client.new(
  ENV.fetch("SEATLAYER_SECRET_KEY"),
  max_retries: 3,   # total attempts
  timeout: 30.0     # seconds, per attempt
)
```
### .NET

```csharp
new SeatLayerClient(secretKey, new SeatLayerClientOptions
{
    MaxRetries = 3,                       // total attempts
    Timeout = TimeSpan.FromSeconds(30),   // per attempt
});
```

## Idempotency

Every mutating request carries an `Idempotency-Key`, generated if you do not
supply one — and **reused across retries**, which is the entire point. A retried
booking collapses into the original instead of becoming a second sale.

Pass your own order id when you want deduplication end to end, across process
restarts and job requeues:

### Node.js

```ts
await seatlayer.inventory.book(
  eventKey,
  { holdId },
  { idempotencyKey: `order-${orderId}` },
);
```
### Python

```python
seatlayer.inventory.book(
    event_key, hold_id=hold_id, idempotency_key=f"order-{order_id}"
)
```
### PHP

```php
$seatlayer->inventory->book($eventKey, holdId: $holdId, idempotencyKey: "order-{$orderId}");
```
### Java

```java
seatlayer.inventory().book(eventKey, holdId, "order-" + orderId);
```
### Go

```go
client.Inventory.Book(ctx, eventKey, seatlayer.BookParams{
    HoldID: holdID, IdempotencyKey: "order-" + orderID,
})
```
### Ruby

```ruby
client.inventory.book(event_key, hold_id: hold_id, idempotency_key: "order-#{order_id}")
```
### .NET

```csharp
await client.Inventory.BookAsync(eventKey, holdId, idempotencyKey: $"order-{orderId}");
```

Keys match `^[A-Za-z0-9._:-]{1,128}$` and are validated client-side, so an
invalid one fails immediately rather than after a round-trip. Reusing a key with
a *different* body answers `409` — that is the server refusing to let one key
stand for two different operations.

## Rate limits

Server calls are budgeted per secret key rather than per IP, because one backend
legitimately speaks for every buyer on your platform. A `429` carries
`Retry-After` plus the `RateLimit-Limit`, `RateLimit-Remaining` and
`RateLimit-Reset` headers describing the active policy — the SDKs read these so
your backoff matches the real window instead of guessing.

## Escape hatch

For surface the SDK does not wrap yet, with the same auth, retries, idempotency
and error mapping:

### Node.js

```ts
await seatlayer.request('POST', '/v1/events/ev_1/some-new-route', { body: {} });
```
### Python

```python
seatlayer.request("POST", "/v1/events/ev_1/some-new-route", body={})
```
### PHP

```php
$seatlayer->request('POST', '/v1/events/ev_1/some-new-route', body: []);
```
### Java

```java
seatlayer.request("POST", "/v1/events/ev_1/some-new-route", null, Map.of("qty", 2));
```
### Go

```go
client.Do(ctx, http.MethodPost, "/v1/events/ev_1/some-new-route", nil,
    map[string]any{"qty": 2}, "")
```
### Ruby

```ruby
client.request("POST", "/v1/events/ev_1/some-new-route", body: { "qty" => 2 })
```
### .NET

```csharp
await client.SendAsync(HttpMethod.Post, "/v1/events/ev_1/some-new-route",
    body: new Dictionary<string, object?> { ["qty"] = 2 });
```

Source: https://docs.seatlayer.io/server-sdk/reliability/index.mdx
