Skip to content

Errors, retries and idempotency

How the server SDKs classify failures, which operations retry, and how create keys differ from booking references.

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.");
}

The names in this table are error families, not copyable class names. Use the tab for your language above or its language guide for the exact spelling; property casing also follows that language.

Error family Status Means
Authentication / authorization 401, 403 Bad, revoked, forbidden, or wrong-mode key
Not found 404 No such resource for this organisation
Conflict 409 Inventory moved, or a guard rejected the change
Validation 422 Understood and rejected
Rate limit 429 Over budget; carries a retry delay when supplied
Connection Never got an answer: DNS, TLS, socket, timeout

API exceptions expose the HTTP status, machine error slug, parsed body, and request ID using language-appropriate names. The request ID comes from X-Request-ID; quote it in support requests. On the wire, error is the required operation discriminator. Some legacy responses also include a code alias; SDKs resolve the machine slug from body.code ?? body.error. Optional message and detail fields remain operation-specific, and additive fields are forward-compatible.

Retries

Retry policy is operation-specific. Reads retry connection failures, 408, 429, and 5xx with exponential backoff and full jitter. A mutation retries automatically only when the API can replay its original HTTP status and body.

Mutation policy Operations SDK behavior
Exact header replay 14 operations listed below Retries transient failures with one reused Idempotency-Key
Exact booking reference Book objects, box-office book objects One automatic attempt; you may reconcile and repeat the exact event + selection + bookingRef
One-time secret Buyer/manage/designer sessions, access-link create/rotate, webhook create One attempt; never automatically retried
Unsupported Every other mutation One attempt; reconcile current state before deciding what to do next

The 14 exact-header-replay wire operations in every 0.8.0 SDK are:

  • core: createChart, copyChart, instantiateTemplate, createEvent, and createWorkspace;
  • Performance Groups: createPerformanceGroup; and
  • Seasons: createSeason, updateSeason, deleteSeason, createSeasonPlan, duplicateSeasonToLive, createSeasonHolderImport, createSeasonRenewalOffers, and createSeasonAmendment.

Those are wire operation IDs. Use the language page for the exact method spelling. No other mutation inherits this policy merely because it accepts an Idempotency-Key.

Best-available hold and booking are deliberately single-attempt. Retrying them can select a different set of seats after the first response is lost. 4xx responses are never retried.

maxRetries is the total-attempt ceiling for eligible reads and these 14 exact header-replay mutations; it does not opt unsafe mutations into retries.

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

There are two different safety contracts. Do not treat them as interchangeable. The wire-level rules behind both are documented in idempotency and conflicts.

Exact HTTP replay for creates

The 14 operations above accept Idempotency-Key. The SDK generates one when absent and reuses it across eligible retries. For 30 days, the same logical request replays the original status and JSON body; a changed body, path, or request scope returns 409 idempotency_conflict.

Keys match ^[A-Za-z0-9._:-]{1,128}$. Their namespace includes the account, live/test mode, and routing environment, but not a particular key id, so key rotation does not strand an in-flight retry.

Booking safety uses bookingRef

Booking does not use generic header replay. Put your immutable order id in the required bookingRef body field:

await seatlayer.inventory.book(
  eventKey,
  { holdId, bookingRef: `order-${orderId}` },
);
seatlayer.inventory.book(
    event_key, hold_id=hold_id, booking_ref=f"order-{order_id}"
)
$seatlayer->inventory->book($eventKey, holdId: $holdId, bookingRef: "order-{$orderId}");
seatlayer.inventory().book(eventKey, holdId, "order-" + orderId);
client.Inventory.Book(ctx, eventKey, seatlayer.BookParams{
    HoldID: holdID, BookingRef: "order-" + orderID,
})
client.inventory.book(event_key, hold_id: hold_id, booking_ref: "order-#{order_id}")
await client.Inventory.BookAsync(eventKey, holdId, bookingRef: $"order-{orderId}");

Repeat only the exact same event and hold/label set with that reference. Seats already booked under it are not sold again, but the replay response may contain an empty booked array. Reusing one bookingRef with additional labels can add those seats; one business order must therefore keep one immutable selection.

Rate limits

The server hold, extend, best-available hold, and best-available booking routes share one durable 600 requests/minute budget per API-key record. Rotating that key does not reset its budget; a different key has an independent bucket. A 429 carries Retry-After and RateLimit-* headers, and the SDK exposes the delay as retryAfterSeconds.

These inventory mutations remain single-attempt because blindly repeating a hold or best-available selection can reserve different inventory. Your order workflow, not the HTTP transport, must decide whether it is safe to wait and submit a new logical request. Other secret-key resource CRUD does not claim a universal account budget.

The OpenAPI contract separately records a 30/minute per-IP, per-Worker-isolate soft ceiling for readiness probes (no standard rate-limit headers), and a durable 300/minute per-session ceiling for browser manage tokens (Retry-After only). Those are not secret-key SDK budgets.

Escape hatch

For a surface the SDK does not wrap yet, the raw client keeps the same auth and error mapping. Raw reads retain transient retry behavior. Raw mutations are single-attempt and do not generate an idempotency key; consult the operation’s OpenAPI policy before building your own recovery flow:

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