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);
|
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' }),
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
|||||||
Reference in New Issue
Block a user