Skip to main content

Resilience & configuration

All resilience features are configured on the Alpaca client (or a raw Configuration). The Alpaca facade enables the defaults described below. Bare generated API clients built from a low-level Configuration have retries off (maxRetries: 0) unless you configure them.

const alpaca = new Alpaca({
keyId,
secret,
timeoutMs: 30_000,
retry: {
maxRetries: 2,
retryDelayMs: 250,
maxDelayMs: 5_000,
retryableStatuses: [408, 425, 429, 500, 502, 503, 504],
respectRetryAfter: true,
},
rateLimit: {
maxRequests: 200,
intervalMs: 60_000,
maxConcurrent: 16,
},
redirect: "error",
});

Retry

On the Alpaca client, retry is on by default (3 attempts = 1 initial + 2 retries) with exponential 250ms..5s backoff, ±20% jitter, and the retryable status set 408, 425, 429, 500, 502, 503, 504. Only safe/idempotent verbs (GET/HEAD/OPTIONS/TRACE) and transient network failures are retried — a non-idempotent POST/PATCH/etc. is never auto-retried, so an order can't be silently replayed by the transport. A Retry-After header is honored over the computed delay for eligible requests: it replaces the computed backoff, but is still capped by maxDelayMs.

Observability

Pass onRetry and onGiveUp to observe attempts. Each fires with a RetryEvent ({ method, url, attempt, maxRetries, delayMs, status?, error? }): status for status-based retries, error for network-error retries. Exceptions thrown from a listener are swallowed so they can never break a request.

retry: {
maxRetries: 3,
onRetry: (e) => console.warn(`retry ${e.attempt}/${e.maxRetries} in ${e.delayMs}ms`),
onGiveUp: (e) => console.error(`gave up after ${e.attempt} retries`, e.status ?? e.error),
}

Order-submission safety

Give each order a stable, unique clientOrderId in its request body. This provides an auditable correlation key and a recovery lookup, but it does not replay a prior response: Alpaca rejects a duplicate client ID.

const clientOrderId = `rebalance-${crypto.randomUUID()}`;

await alpaca.trading.orders.market({
symbol: "AAPL",
side: "buy",
qty: 1,
clientOrderId,
});

Order-placement POSTs are never auto-retried. If a FetchError makes the outcome ambiguous, call alpaca.trading.orders.getOrderByClientOrderId({ clientOrderId }) before any further submission. A lookup miss is not proof that the placement failed, and the SDK does not promise that a record will eventually appear; follow your application's reconciliation policy.

submitAndWait adds a narrowly scoped workflow: it waits for the server's listening acknowledgement for trade_updates, then issues one placement per invocation. It preserves a supplied client ID or creates one once, never re-places on stream reconnect, and applies one deadline across connect, authentication, subscription, REST placement, and terminal-event waiting. After an ambiguous placement FetchError, it performs one getOrderByClientOrderId request and continues waiting when appropriate. Generic market/limit/submit calls do not reconcile automatically. A timeout can still leave the outcome ambiguous; submitAndWait does not promise exactly-once execution or eventual lookup visibility. Post-placement workflow failures reject with SubmitAndWaitError, which exposes clientOrderId, an optional confirmed orderId, phase, placementAmbiguous, and the original cause. Reconcile the client ID before resubmitting when placement remains ambiguous.

Timeouts

timeoutMs is a fresh per-attempt deadline (default 30s; pass 0 to disable). Each attempt's budget starts before client-side rate-limit acquisition and covers the rate-limit wait, pre middleware, fetch, error/post middleware, and successful or error response-body consumption.

Retry backoff is outside the finished attempt's budget; the next attempt gets a new full deadline. A caller AbortSignal spans the whole operation and can cancel an active attempt or its retry backoff. Pass it through a generated method's initOverrides: it follows the request-parameters object when a method has one, and is the first argument for parameterless methods:

const controller = new AbortController();
const request = alpaca.trading.orders.getAllOrders(
{},
{ signal: controller.signal },
);

controller.abort();
await request;

Cancellation from any phase rejects with FetchError. A default abort from controller.abort() has an AbortError cause. A custom reason that is an Error or DOMException remains the cause; primitive custom reasons are normalized to an AbortError rather than exposed as raw values. An attempt deadline has a TimeoutError cause. Neither cancellation kind is retried, and POST remains excluded from automatic retry.

Redirects

Requests default to redirect: "error", so any 3xx fails fast instead of being followed. Alpaca's APIs never redirect, and following one off-host would forward the APCA-API-* secret headers to the redirect target (unlike Authorization, custom headers are not stripped on a cross-origin redirect). Set redirect: "follow" to opt back into the platform default behind a redirecting proxy.

Rate limiting

The Alpaca client enables a safe default token bucket (~200 req/min, applied independently to the trading and market-data hosts). Tune it with a rateLimit config or pass rateLimit: false to opt out. Raw API classes created from a bare Configuration do not enable a limiter unless you configure one.

Typed errors & response headers

Non-2xx responses reject with a typed ApiError (subclasses: AuthError 401, PermissionError 403, NotFoundError 404, ValidationError 400/422, RateLimitError 429), each carrying status, code, rateLimit, and requestId. Handle API responses separately from transport failures:

import {
ApiError,
FetchError,
orders,
} from "@alpacahq/alpaca-trade-api";

const clientOrderId = `resilient-order-${crypto.randomUUID()}`;
const postOrderRequest = orders.buildMarketOrder({
symbol: "AAPL",
side: "buy",
qty: 1,
clientOrderId,
});

try {
await alpaca.trading.orders.postOrder({ postOrderRequest });
} catch (error) {
if (error instanceof ApiError) {
console.error({
status: error.status,
code: error.code,
message: error.message,
requestId: error.requestId,
rateLimit: error.rateLimit,
});
} else if (error instanceof FetchError) {
// DNS, connection, timeout, or cancellation failure; inspect the cause.
console.error("transport failed", error.cause);
// For a POST order, the outcome may be ambiguous: reconcile the stable
// clientOrderId before resubmitting.
} else {
throw error;
}
}

An ApiError proves that Alpaca returned an HTTP response. A FetchError instead wraps the underlying transport cause; for a submitted POST, that transport failure can be ambiguous because the server may have accepted the request before the response was lost. Reconcile by stable clientOrderId before resubmitting an order.

For metadata on a successful call, wrap the generated *Raw method with withResponse:

import { withResponse } from "@alpacahq/alpaca-trade-api";

const res = await withResponse(alpaca.trading.account.getAccountRaw());
res.data; // typed body
res.status; // 200
res.headers.get("X-Request-ID");
res.rateLimit?.remaining;

The response body is consumed once; use res.data instead of reading the raw body again.

Middleware observability

The SDK provides logging and metrics middleware on top of the transport's pre/post/onError hooks. They observe each request attempt without changing its response, compose with retries, and isolate logger, metrics-sink, and request-ID-generator failures.

import { Alpaca, middleware } from "@alpacahq/alpaca-trade-api";

const alpaca = new Alpaca({
keyId,
secret,
middleware: [
middleware.loggingMiddleware({
logger: console,
level: "info",
}),
middleware.metricsMiddleware({
onRequest: (metric) => {
statsd.timing("alpaca.request", metric.durationMs, {
method: metric.method,
status: metric.status,
});
},
}),
],
});

loggingMiddleware emits method, URL, status, duration, and a generated request ID for each attempt. Headers are omitted unless logHeaders: true; when included, APCA-API-KEY-ID, APCA-API-SECRET-KEY, and Authorization are redacted by default. Keep those defaults, or provide an equally strict redactHeaders list when extending them.

Both built-in middleware accept genRequestId: () => string for your own correlation IDs. The default uses crypto.randomUUID() when available and a counter fallback otherwise. If a custom generator throws, the built-in generator is used so observability cannot fail the request.