Skip to content

Errors, retries and idempotency

How the server SDKs classify failures, when they retry, and why every mutation carries an idempotency key.

Updated View as Markdown

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.

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;
}
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
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;
}
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;
}
_, 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
}
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
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.

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.

new SeatLayer({
  secretKey: process.env.SEATLAYER_SECRET_KEY!,
  maxRetries: 3,      // total attempts
  timeoutMs: 30_000,  // per attempt
});
SeatLayer(
    os.environ["SEATLAYER_SECRET_KEY"],
    max_retries=3,   # total attempts
    timeout=30.0,    # seconds, per attempt
)
new SeatLayer(
    getenv('SEATLAYER_SECRET_KEY'),
    maxRetries: 3,   // total attempts
    timeout: 30.0,   // seconds, per attempt
);
SeatLayer.builder()
    .secretKey(System.getenv("SEATLAYER_SECRET_KEY"))
    .maxRetries(3)                        // total attempts
    .timeout(Duration.ofSeconds(30))      // per attempt
    .build();
client, err := seatlayer.New(
    os.Getenv("SEATLAYER_SECRET_KEY"),
    seatlayer.WithMaxRetries(3),                                  // total attempts
    seatlayer.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),  // per attempt
)
SeatLayer::Client.new(
  ENV.fetch("SEATLAYER_SECRET_KEY"),
  max_retries: 3,   # total attempts
  timeout: 30.0     # seconds, per attempt
)
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:

await seatlayer.inventory.book(
  eventKey,
  { holdId },
  { idempotencyKey: `order-${orderId}` },
);
seatlayer.inventory.book(
    event_key, hold_id=hold_id, idempotency_key=f"order-{order_id}"
)
$seatlayer->inventory->book($eventKey, holdId: $holdId, idempotencyKey: "order-{$orderId}");
seatlayer.inventory().book(eventKey, holdId, "order-" + orderId);
client.Inventory.Book(ctx, eventKey, seatlayer.BookParams{
    HoldID: holdID, IdempotencyKey: "order-" + orderID,
})
client.inventory.book(event_key, hold_id: hold_id, idempotency_key: "order-#{order_id}")
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:

await seatlayer.request('POST', '/v1/events/ev_1/some-new-route', { body: {} });
seatlayer.request("POST", "/v1/events/ev_1/some-new-route", body={})
$seatlayer->request('POST', '/v1/events/ev_1/some-new-route', body: []);
seatlayer.request("POST", "/v1/events/ev_1/some-new-route", null, Map.of("qty", 2));
client.Do(ctx, http.MethodPost, "/v1/events/ev_1/some-new-route", nil,
    map[string]any{"qty": 2}, "")
client.request("POST", "/v1/events/ev_1/some-new-route", body: { "qty" => 2 })
await client.SendAsync(HttpMethod.Post, "/v1/events/ev_1/some-new-route",
    body: new Dictionary<string, object?> { ["qty"] = 2 });
Navigation

Type to search…

↑↓ navigate↵ selectEsc close