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:
@@ -119,6 +119,29 @@ describe('Alpaca', () => {
|
||||
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 () => {
|
||||
const client = mockClient({
|
||||
createOrder: vi.fn().mockResolvedValue({ id: 'order-2', symbol: 'TQQQ', filled_avg_price: '51.00', filled_qty: '10', side: 'sell', status: 'filled' }),
|
||||
|
||||
@@ -76,6 +76,14 @@ export interface AlpacaClient {
|
||||
}): 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 {
|
||||
private alpaca: 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() {
|
||||
return this.alpaca.getAccount();
|
||||
return this.retry('getAccount', () => this.alpaca.getAccount());
|
||||
}
|
||||
public async getAssets() {
|
||||
|
||||
return this.alpaca.getAssets({
|
||||
return this.retry('getAssets', () => this.alpaca.getAssets({
|
||||
status : 'active',
|
||||
asset_class : 'us_equity'
|
||||
});
|
||||
}));
|
||||
}
|
||||
public async getAsset(symbol: string) {
|
||||
return this.alpaca.getAsset(symbol);
|
||||
return this.retry('getAsset', () => this.alpaca.getAsset(symbol));
|
||||
}
|
||||
|
||||
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}`);
|
||||
return clock;
|
||||
}
|
||||
|
||||
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}`);
|
||||
return quote.AskPrice;
|
||||
}
|
||||
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}`);
|
||||
return quote.BidPrice;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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> {
|
||||
return this.alpaca.getOrder(id);
|
||||
return this.retry('getOrder', () => this.alpaca.getOrder(id));
|
||||
}
|
||||
|
||||
private async waitForFill(orderId: string): Promise<AlpacaOrder> {
|
||||
@@ -148,13 +168,13 @@ export class Alpaca {
|
||||
|
||||
public async buy(symbol: string, dollarAmount: number): Promise<OrderFill> {
|
||||
logger.info(`buying ${symbol} for $${dollarAmount}`);
|
||||
const order = await this.alpaca.createOrder({
|
||||
const order = await this.retry('createOrder', () => this.alpaca.createOrder({
|
||||
symbol,
|
||||
notional: dollarAmount,
|
||||
side: 'buy',
|
||||
type: 'market',
|
||||
time_in_force: 'day',
|
||||
});
|
||||
}));
|
||||
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}`);
|
||||
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> {
|
||||
logger.info(`selling ${qty} shares of ${symbol}`);
|
||||
const order = await this.alpaca.createOrder({
|
||||
const order = await this.retry('createOrder', () => this.alpaca.createOrder({
|
||||
symbol,
|
||||
qty,
|
||||
side: 'sell',
|
||||
type: 'market',
|
||||
time_in_force: 'day',
|
||||
});
|
||||
}));
|
||||
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}`);
|
||||
return { price: parseFloat(filled.filled_avg_price), qty: parseFloat(filled.filled_qty) };
|
||||
|
||||
Reference in New Issue
Block a user