This commit is contained in:
2026-04-19 13:23:00 +03:00
parent e4c74025e5
commit 1346924387
25 changed files with 1639 additions and 1076 deletions
+267
View File
@@ -0,0 +1,267 @@
import axios, {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from "axios";
import { Logger } from "@nestjs/common";
export type AiCircuitState = "closed" | "open";
export interface AiEngineClientOptions {
baseUrl: string;
logger: Logger;
serviceName: string;
timeoutMs?: number;
maxRetries?: number;
retryDelayMs?: number;
circuitBreakerThreshold?: number;
circuitBreakerCooldownMs?: number;
}
interface AiEngineRequestConfig extends AxiosRequestConfig {
retryCount?: number;
}
export interface AiEngineClientSnapshot {
state: AiCircuitState;
consecutiveFailures: number;
openedAt: string | null;
}
export class AiEngineRequestError extends Error {
status?: number;
detail?: unknown;
isCircuitOpen: boolean;
constructor(
message: string,
options: {
status?: number;
detail?: unknown;
isCircuitOpen?: boolean;
} = {},
) {
super(message);
this.name = "AiEngineRequestError";
this.status = options.status;
this.detail = options.detail;
this.isCircuitOpen = options.isCircuitOpen ?? false;
}
}
export class AiEngineClient {
private readonly axiosClient: AxiosInstance;
private readonly logger: Logger;
private readonly serviceName: string;
private readonly defaultTimeoutMs: number;
private readonly maxRetries: number;
private readonly retryDelayMs: number;
private readonly circuitBreakerThreshold: number;
private readonly circuitBreakerCooldownMs: number;
private consecutiveFailures = 0;
private circuitOpenedAt: number | null = null;
constructor(options: AiEngineClientOptions) {
this.logger = options.logger;
this.serviceName = options.serviceName;
this.defaultTimeoutMs = options.timeoutMs ?? 30000;
this.maxRetries = options.maxRetries ?? 2;
this.retryDelayMs = options.retryDelayMs ?? 750;
this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 3;
this.circuitBreakerCooldownMs =
options.circuitBreakerCooldownMs ?? 30000;
this.axiosClient = axios.create({
baseURL: options.baseUrl,
timeout: this.defaultTimeoutMs,
});
}
async get<T>(
path: string,
config?: AiEngineRequestConfig,
): Promise<AxiosResponse<T>> {
return this.request<T>({
method: "get",
url: path,
...config,
});
}
async post<T>(
path: string,
data?: unknown,
config?: AiEngineRequestConfig,
): Promise<AxiosResponse<T>> {
return this.request<T>({
method: "post",
url: path,
data,
...config,
});
}
getSnapshot(): AiEngineClientSnapshot {
return {
state: this.isCircuitOpen() ? "open" : "closed",
consecutiveFailures: this.consecutiveFailures,
openedAt: this.circuitOpenedAt
? new Date(this.circuitOpenedAt).toISOString()
: null,
};
}
private async request<T>(config: AiEngineRequestConfig): Promise<AxiosResponse<T>> {
this.ensureCircuitAvailable();
const retries = this.resolveRetryCount(config);
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const response = await this.axiosClient.request<T>({
timeout: this.defaultTimeoutMs,
...config,
});
this.resetFailures();
return response;
} catch (error) {
lastError = error;
const shouldRetry = attempt < retries && this.isRetriableError(error);
if (!shouldRetry) {
this.registerFailure(error);
throw this.toRequestError(error);
}
this.logger.warn(
`[${this.serviceName}] AI request retry ${attempt + 1}/${retries} for ${config.method?.toUpperCase()} ${config.url}`,
);
await this.delay(this.retryDelayMs * (attempt + 1));
}
}
this.registerFailure(lastError);
throw this.toRequestError(lastError);
}
private resolveRetryCount(config: AiEngineRequestConfig): number {
if (typeof config.retryCount === "number" && config.retryCount >= 0) {
return config.retryCount;
}
return this.maxRetries;
}
private ensureCircuitAvailable() {
if (!this.isCircuitOpen()) {
return;
}
const remainingCooldown =
this.circuitBreakerCooldownMs - (Date.now() - (this.circuitOpenedAt ?? 0));
if (remainingCooldown > 0) {
throw new AiEngineRequestError("AI engine circuit breaker is open", {
status: 503,
detail: {
cooldownRemainingMs: remainingCooldown,
},
isCircuitOpen: true,
});
}
this.logger.warn(
`[${this.serviceName}] AI circuit breaker cooldown elapsed, allowing a recovery attempt`,
);
this.circuitOpenedAt = null;
}
private isCircuitOpen(): boolean {
return this.circuitOpenedAt !== null;
}
private resetFailures() {
this.consecutiveFailures = 0;
this.circuitOpenedAt = null;
}
private registerFailure(error: unknown) {
this.consecutiveFailures += 1;
const normalizedError = this.toRequestError(error);
this.logger.warn(
`[${this.serviceName}] AI request failed (${this.consecutiveFailures}/${this.circuitBreakerThreshold}): ${normalizedError.message}`,
);
if (this.consecutiveFailures >= this.circuitBreakerThreshold) {
this.circuitOpenedAt = Date.now();
this.logger.error(
`[${this.serviceName}] AI circuit breaker opened after ${this.consecutiveFailures} consecutive failures`,
);
}
}
private isRetriableError(error: unknown): boolean {
if (!axios.isAxiosError(error)) {
return false;
}
if (!error.response) {
return true;
}
const status = error.response.status;
return status >= 500 || status === 429 || error.code === "ECONNABORTED";
}
private toRequestError(error: unknown): AiEngineRequestError {
if (error instanceof AiEngineRequestError) {
return error;
}
if (axios.isAxiosError(error)) {
const detail = error.response?.data ?? error.message;
const status = error.response?.status;
const message = this.buildAxiosErrorMessage(error);
return new AiEngineRequestError(message, {
status,
detail,
});
}
if (error instanceof Error) {
return new AiEngineRequestError(error.message);
}
return new AiEngineRequestError("Unknown AI engine error", {
detail: error,
});
}
private buildAxiosErrorMessage(error: AxiosError): string {
if (error.code === "ECONNABORTED") {
return "AI engine request timed out";
}
if (!error.response) {
return "AI engine is unreachable";
}
const detail =
(error.response.data as Record<string, unknown> | undefined)?.detail ??
error.message;
return typeof detail === "string"
? detail
: `AI engine request failed with status ${error.response.status}`;
}
private async delay(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
}