Assist_Design/apps/bff/src/integrations/freebit/services/freebit-client.service.ts

268 lines
8.6 KiB
TypeScript
Raw Normal View History

import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { RetryableErrors, withRetry } from "@bff/core/utils/retry.util.js";
import { redactForLogs } from "@bff/core/logging/redaction.util.js";
import { FreebitAuthService } from "./freebit-auth.service.js";
import { FreebitError } from "./freebit-error.service.js";
interface FreebitResponseBase {
resultCode?: string | number;
status?: {
message?: string;
statusCode?: string | number;
};
}
@Injectable()
export class FreebitClientService {
constructor(
private readonly authService: FreebitAuthService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Make an authenticated request to Freebit API with retry logic
*/
async makeAuthenticatedRequest<TResponse extends FreebitResponseBase, TPayload extends object>(
endpoint: string,
payload: TPayload
): Promise<TResponse> {
const config = this.authService.getConfig();
const authKey = await this.authService.getAuthKey();
const url = this.buildUrl(config.baseUrl, endpoint);
const requestPayload = { ...payload, authKey };
let attempt = 0;
return withRetry(
async () => {
attempt += 1;
this.logger.debug(`Freebit API request`, {
url,
attempt,
maxAttempts: config.retryAttempts,
payload: redactForLogs(requestPayload),
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `json=${JSON.stringify(requestPayload)}`,
signal: controller.signal,
});
if (!response.ok) {
const isProd = process.env.NODE_ENV === "production";
const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response);
this.logger.error("Freebit API HTTP error", {
url,
status: response.status,
statusText: response.statusText,
...(bodySnippet ? { responseBodySnippet: bodySnippet } : {}),
attempt,
});
throw new FreebitError(
`HTTP ${response.status}: ${response.statusText}`,
response.status.toString()
);
}
const responseData = (await response.json()) as TResponse;
const resultCode = this.normalizeResultCode(responseData.resultCode);
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
if (resultCode && resultCode !== "100") {
const isProd = process.env.NODE_ENV === "production";
this.logger.warn("Freebit API returned error response", {
url,
resultCode,
statusCode,
statusMessage: responseData.status?.message,
...(isProd ? {} : { response: redactForLogs(responseData as unknown) }),
});
throw new FreebitError(
`API Error: ${responseData.status?.message || "Unknown error"}`,
resultCode,
statusCode,
responseData.status?.message
);
}
this.logger.debug("Freebit API request successful", { url, resultCode });
return responseData;
} finally {
clearTimeout(timeoutId);
}
},
{
maxAttempts: config.retryAttempts,
baseDelayMs: 1000,
maxDelayMs: 10000,
isRetryable: error => {
if (error instanceof FreebitError) {
if (error.isAuthError() && attempt === 1) {
this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url });
this.authService.clearAuthCache();
return true;
}
return error.isRetryable();
}
return RetryableErrors.isTransientError(error);
},
logger: this.logger,
logContext: "Freebit API request",
}
);
}
/**
* Make an authenticated JSON request to Freebit API (for PA05-41)
*/
async makeAuthenticatedJsonRequest<
TResponse extends FreebitResponseBase,
TPayload extends object,
>(endpoint: string, payload: TPayload): Promise<TResponse> {
const config = this.authService.getConfig();
const url = this.buildUrl(config.baseUrl, endpoint);
let attempt = 0;
return withRetry(
async () => {
attempt += 1;
this.logger.debug("Freebit JSON API request", {
url,
attempt,
maxAttempts: config.retryAttempts,
payload: redactForLogs(payload),
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!response.ok) {
throw new FreebitError(
`HTTP ${response.status}: ${response.statusText}`,
response.status.toString()
);
}
const responseData = (await response.json()) as TResponse;
const resultCode = this.normalizeResultCode(responseData.resultCode);
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
if (resultCode && resultCode !== "100") {
const isProd = process.env.NODE_ENV === "production";
this.logger.error("Freebit API returned error result code", {
url,
resultCode,
statusCode,
message: responseData.status?.message,
...(isProd ? {} : { response: redactForLogs(responseData as unknown) }),
attempt,
});
throw new FreebitError(
`API Error: ${responseData.status?.message || "Unknown error"}`,
resultCode,
statusCode,
responseData.status?.message
);
}
this.logger.debug("Freebit JSON API request successful", { url, resultCode });
return responseData;
} finally {
clearTimeout(timeoutId);
}
},
{
maxAttempts: config.retryAttempts,
baseDelayMs: 1000,
maxDelayMs: 10000,
isRetryable: error => {
if (error instanceof FreebitError) {
if (error.isAuthError() && attempt === 1) {
this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url });
this.authService.clearAuthCache();
return true;
}
return error.isRetryable();
}
return RetryableErrors.isTransientError(error);
},
logger: this.logger,
logContext: "Freebit JSON API request",
}
);
}
/**
* Make a simple request without authentication (for health checks)
*/
async makeSimpleRequest(endpoint: string): Promise<boolean> {
const config = this.authService.getConfig();
const url = this.buildUrl(config.baseUrl, endpoint);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeout);
const response = await fetch(url, {
method: "GET",
signal: controller.signal,
});
clearTimeout(timeout);
return response.ok;
} catch (error) {
this.logger.debug("Simple request failed", {
url,
error: extractErrorMessage(error),
});
return false;
}
}
/**
* Ensure proper URL construction - remove double slashes
*/
private buildUrl(baseUrl: string, endpoint: string): string {
const cleanBase = baseUrl.replace(/\/$/, "");
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
return `${cleanBase}${cleanEndpoint}`;
}
private async safeReadBodySnippet(response: Response): Promise<string | undefined> {
try {
const text = await response.text();
return text ? text.slice(0, 300) : undefined;
} catch {
return undefined;
}
}
private normalizeResultCode(code?: string | number | null): string | undefined {
if (code === undefined || code === null) {
return undefined;
}
const normalized = String(code).trim();
return normalized.length > 0 ? normalized : undefined;
}
}