- MoexHttpClient: infrastructure (axios, rate limiter, circuit breaker) - MoexSecuritiesClient: search and security descriptions - MoexMarketDataClient: share/bond market data and batch queries - MoexCandlesClient: candle data - MoexHistoryClient: share/bond history - MoexDividendsClient: dividend data - Removed @Global() from MoexClientModule - Updated all 7 consumers with explicit DI - All 141 tests passing
77 lines
2.5 KiB
TypeScript
77 lines
2.5 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import axios, { AxiosInstance } from 'axios';
|
|
import PQueue from 'p-queue';
|
|
|
|
@Injectable()
|
|
export class MoexHttpClient {
|
|
private readonly logger = new Logger(MoexHttpClient.name);
|
|
private readonly client: AxiosInstance;
|
|
private readonly queue: PQueue;
|
|
private circuitOpen = false;
|
|
private circuitErrorCount = 0;
|
|
private readonly threshold: number;
|
|
private readonly resetMs: number;
|
|
|
|
constructor(private configService: ConfigService) {
|
|
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
|
|
this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
|
|
this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
|
|
const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
|
|
|
|
this.client = axios.create({
|
|
baseURL: baseUrl,
|
|
timeout: 10000,
|
|
paramsSerializer: { indexes: null },
|
|
});
|
|
|
|
this.queue = new PQueue({
|
|
interval: 1000,
|
|
intervalCap: rateLimit,
|
|
});
|
|
}
|
|
|
|
async request<T>(path: string, params?: Record<string, string>): Promise<T> {
|
|
if (this.circuitOpen) {
|
|
throw new Error('Circuit breaker is open — MOEX requests paused');
|
|
}
|
|
|
|
return this.queue.add(async () => {
|
|
try {
|
|
const jsonPath = path + '.json';
|
|
const response = await this.client.get(jsonPath, {
|
|
params: { ...params, 'iss.meta': 'off' },
|
|
});
|
|
this.circuitErrorCount = 0;
|
|
return response.data as T;
|
|
} catch (error) {
|
|
this.circuitErrorCount++;
|
|
if (this.circuitErrorCount >= this.threshold) {
|
|
this.circuitOpen = true;
|
|
this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`);
|
|
setTimeout(() => {
|
|
this.circuitOpen = false;
|
|
this.circuitErrorCount = 0;
|
|
this.logger.log('Circuit breaker reset');
|
|
}, this.resetMs);
|
|
}
|
|
throw error;
|
|
}
|
|
}) as Promise<T>;
|
|
}
|
|
|
|
extractTable(data: Record<string, unknown>, name: string): Record<string, unknown>[] {
|
|
const table = data[name] as Record<string, unknown> | undefined;
|
|
if (!table || !table.columns || !table.data) return [];
|
|
const columns = table.columns as string[];
|
|
const rows = table.data as unknown[][];
|
|
return rows.map((row) => {
|
|
const obj: Record<string, unknown> = {};
|
|
columns.forEach((col, i) => {
|
|
obj[col] = row[i];
|
|
});
|
|
return obj;
|
|
});
|
|
}
|
|
}
|