Add retry logic for transient API errors

Wraps all Alpaca API calls with automatic retries (up to 3 attempts
with increasing backoff) for transient errors like ECONNRESET, socket
hang up, ETIMEDOUT, and 429/502/503/504 responses. Non-transient
errors like 422 or auth failures are thrown immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-02-12 22:55:55 -07:00
parent 4ca7073d77
commit 019b630d95
2 changed files with 58 additions and 15 deletions

View File

@@ -119,6 +119,29 @@ describe('Alpaca', () => {
expect(client.getOrder).toHaveBeenCalledTimes(2); expect(client.getOrder).toHaveBeenCalledTimes(2);
}); });
it('retries on transient ECONNRESET error', async () => {
const econnreset = new Error('socket hang up');
(econnreset as { code?: string }).code = 'ECONNRESET';
const client = mockClient({
getClock: vi.fn()
.mockRejectedValueOnce(econnreset)
.mockResolvedValueOnce({ is_open: true, next_open: '2025-01-01T14:30:00Z', next_close: '2025-01-01T21:00:00Z' }),
});
const alpaca = new Alpaca(false, client);
const clock = await alpaca.getClock();
expect(clock.is_open).toBe(true);
expect(client.getClock).toHaveBeenCalledTimes(2);
});
it('does not retry on non-transient errors', async () => {
const client = mockClient({
getClock: vi.fn().mockRejectedValue(new Error('invalid credentials')),
});
const alpaca = new Alpaca(false, client);
await expect(alpaca.getClock()).rejects.toThrow('invalid credentials');
expect(client.getClock).toHaveBeenCalledTimes(1);
});
it('sell places a market sell order by qty and returns fill', async () => { it('sell places a market sell order by qty and returns fill', async () => {
const client = mockClient({ const client = mockClient({
createOrder: vi.fn().mockResolvedValue({ id: 'order-2', symbol: 'TQQQ', filled_avg_price: '51.00', filled_qty: '10', side: 'sell', status: 'filled' }), createOrder: vi.fn().mockResolvedValue({ id: 'order-2', symbol: 'TQQQ', filled_avg_price: '51.00', filled_qty: '10', side: 'sell', status: 'filled' }),

View File

@@ -76,6 +76,14 @@ export interface AlpacaClient {
}): Promise<AlpacaOrder>; }): Promise<AlpacaOrder>;
} }
function isTransient(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message;
if (msg.includes('ECONNRESET') || msg.includes('socket hang up') || msg.includes('ETIMEDOUT')) return true;
const status = (err as { response?: { status?: number } }).response?.status;
return status === 429 || status === 502 || status === 503 || status === 504;
}
export class Alpaca { export class Alpaca {
private alpaca: AlpacaClient; private alpaca: AlpacaClient;
constructor(live = false, client?: AlpacaClient) { constructor(live = false, client?: AlpacaClient) {
@@ -90,46 +98,58 @@ export class Alpaca {
} }
} }
private async retry<T>(label: string, fn: () => Promise<T>, maxRetries = 3): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt >= maxRetries || !isTransient(err)) throw err;
const delay = attempt * 2000;
logger.info(`${label} failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
public async getAccount() { public async getAccount() {
return this.alpaca.getAccount(); return this.retry('getAccount', () => this.alpaca.getAccount());
} }
public async getAssets() { public async getAssets() {
return this.retry('getAssets', () => this.alpaca.getAssets({
return this.alpaca.getAssets({
status : 'active', status : 'active',
asset_class : 'us_equity' asset_class : 'us_equity'
}); }));
} }
public async getAsset(symbol: string) { public async getAsset(symbol: string) {
return this.alpaca.getAsset(symbol); return this.retry('getAsset', () => this.alpaca.getAsset(symbol));
} }
public async getClock() { public async getClock() {
const clock = await this.alpaca.getClock(); const clock = await this.retry('getClock', () => this.alpaca.getClock());
logger.debug(`clock: is_open=${clock.is_open}, next_open=${clock.next_open}`); logger.debug(`clock: is_open=${clock.is_open}, next_open=${clock.next_open}`);
return clock; return clock;
} }
public async getLatestAsk(symbol: string): Promise<number> { public async getLatestAsk(symbol: string): Promise<number> {
const quote = await this.alpaca.getLatestQuote(symbol); const quote = await this.retry('getLatestQuote', () => this.alpaca.getLatestQuote(symbol));
logger.debug(`${symbol} ask: ${quote.AskPrice}`); logger.debug(`${symbol} ask: ${quote.AskPrice}`);
return quote.AskPrice; return quote.AskPrice;
} }
public async getLatestBid(symbol: string): Promise<number> { public async getLatestBid(symbol: string): Promise<number> {
const quote = await this.alpaca.getLatestQuote(symbol); const quote = await this.retry('getLatestQuote', () => this.alpaca.getLatestQuote(symbol));
logger.debug(`${symbol} bid: ${quote.BidPrice}`); logger.debug(`${symbol} bid: ${quote.BidPrice}`);
return quote.BidPrice; return quote.BidPrice;
} }
public async getLatestSpread(symbol: string): Promise<number> { public async getLatestSpread(symbol: string): Promise<number> {
const quote = await this.alpaca.getLatestQuote(symbol); const quote = await this.retry('getLatestQuote', () => this.alpaca.getLatestQuote(symbol));
return quote.AskPrice - quote.BidPrice; return quote.AskPrice - quote.BidPrice;
} }
public async getLatestTrades(symbols: string[]) { public async getLatestTrades(symbols: string[]) {
return this.alpaca.getLatestTrades(symbols); return this.retry('getLatestTrades', () => this.alpaca.getLatestTrades(symbols));
} }
public async getOrder(id: string): Promise<AlpacaOrder> { public async getOrder(id: string): Promise<AlpacaOrder> {
return this.alpaca.getOrder(id); return this.retry('getOrder', () => this.alpaca.getOrder(id));
} }
private async waitForFill(orderId: string): Promise<AlpacaOrder> { private async waitForFill(orderId: string): Promise<AlpacaOrder> {
@@ -148,13 +168,13 @@ export class Alpaca {
public async buy(symbol: string, dollarAmount: number): Promise<OrderFill> { public async buy(symbol: string, dollarAmount: number): Promise<OrderFill> {
logger.info(`buying ${symbol} for $${dollarAmount}`); logger.info(`buying ${symbol} for $${dollarAmount}`);
const order = await this.alpaca.createOrder({ const order = await this.retry('createOrder', () => this.alpaca.createOrder({
symbol, symbol,
notional: dollarAmount, notional: dollarAmount,
side: 'buy', side: 'buy',
type: 'market', type: 'market',
time_in_force: 'day', time_in_force: 'day',
}); }));
const filled = order.status === 'filled' ? order : await this.waitForFill(order.id); const filled = order.status === 'filled' ? order : await this.waitForFill(order.id);
logger.info(`bought ${symbol} — filled at ${filled.filled_avg_price}, qty ${filled.filled_qty}, order ${filled.id}`); logger.info(`bought ${symbol} — filled at ${filled.filled_avg_price}, qty ${filled.filled_qty}, order ${filled.id}`);
return { price: parseFloat(filled.filled_avg_price), qty: parseFloat(filled.filled_qty) }; return { price: parseFloat(filled.filled_avg_price), qty: parseFloat(filled.filled_qty) };
@@ -162,13 +182,13 @@ export class Alpaca {
public async sell(symbol: string, qty: number): Promise<OrderFill> { public async sell(symbol: string, qty: number): Promise<OrderFill> {
logger.info(`selling ${qty} shares of ${symbol}`); logger.info(`selling ${qty} shares of ${symbol}`);
const order = await this.alpaca.createOrder({ const order = await this.retry('createOrder', () => this.alpaca.createOrder({
symbol, symbol,
qty, qty,
side: 'sell', side: 'sell',
type: 'market', type: 'market',
time_in_force: 'day', time_in_force: 'day',
}); }));
const filled = order.status === 'filled' ? order : await this.waitForFill(order.id); const filled = order.status === 'filled' ? order : await this.waitForFill(order.id);
logger.info(`sold ${symbol} — filled at ${filled.filled_avg_price}, qty ${filled.filled_qty}, order ${filled.id}`); logger.info(`sold ${symbol} — filled at ${filled.filled_avg_price}, qty ${filled.filled_qty}, order ${filled.id}`);
return { price: parseFloat(filled.filled_avg_price), qty: parseFloat(filled.filled_qty) }; return { price: parseFloat(filled.filled_avg_price), qty: parseFloat(filled.filled_qty) };