Assist_Design/apps/bff/src/integrations/freebit/services/freebit-client.service.ts
tema 9c796f59da Enhance Freebit integration and improve error handling
- Added FreebitMapperService to facilitate account normalization and improve code organization.
- Updated FreebitAuthService to streamline error handling and response parsing, replacing custom exceptions with standard error messages.
- Enhanced FreebitClientService to ensure proper URL construction and improved logging for API errors.
- Refactored FreebitOperationsService to include new request types and validation, ensuring better handling of SIM operations.
- Updated FreebitOrchestratorService to utilize the new mapper for account normalization across various methods.
- Improved SIM management features in the portal, including better handling of SIM details and usage information.
- Refactored components to enhance user experience and maintainability, including updates to the ChangePlanModal and SimActions components.
2025-11-21 18:41:14 +09:00

296 lines
9.6 KiB
TypeScript

import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { FreebitAuthService } from "./freebit-auth.service";
import { FreebitError } from "./freebit-error.service";
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 authKey = await this.authService.getAuthKey();
const config = this.authService.getConfig();
const requestPayload = { ...payload, authKey };
// Ensure proper URL construction - remove double slashes
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const url = `${baseUrl}${cleanEndpoint}`;
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
try {
this.logger.debug(`Freebit API request (attempt ${attempt}/${config.retryAttempts})`, {
url,
payload: this.sanitizePayload(requestPayload),
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeout);
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `json=${JSON.stringify(requestPayload)}`,
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
const errorText = await response.text().catch(() => "Unable to read response body");
this.logger.error(`Freebit API HTTP error`, {
url,
status: response.status,
statusText: response.statusText,
responseBody: errorText,
attempt,
payload: this.sanitizePayload(requestPayload),
});
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") {
this.logger.warn("Freebit API returned error response", {
url,
resultCode,
statusCode,
statusMessage: responseData.status?.message,
fullResponse: responseData,
});
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;
} catch (error: unknown) {
if (error instanceof FreebitError) {
if (error.isAuthError() && attempt === 1) {
this.logger.warn("Auth error detected, clearing cache and retrying");
this.authService.clearAuthCache();
continue;
}
if (!error.isRetryable() || attempt === config.retryAttempts) {
throw error;
}
}
if (attempt === config.retryAttempts) {
const message = getErrorMessage(error);
this.logger.error(`Freebit API request failed after ${config.retryAttempts} attempts`, {
url,
error: message,
});
throw new FreebitError(`Request failed: ${message}`);
}
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
this.logger.warn(`Freebit API request failed, retrying in ${delay}ms`, {
url,
attempt,
error: getErrorMessage(error),
});
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new FreebitError("Request failed after all retry attempts");
}
/**
* 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();
// Ensure proper URL construction - remove double slashes
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const url = `${baseUrl}${cleanEndpoint}`;
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
try {
this.logger.debug(`Freebit JSON API request (attempt ${attempt}/${config.retryAttempts})`, {
url,
payload: this.sanitizePayload(payload as Record<string, unknown>),
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeout);
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeout);
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") {
this.logger.error(`Freebit API returned error result code`, {
url,
resultCode,
statusCode,
message: responseData.status?.message,
responseData: this.sanitizePayload(responseData as unknown as Record<string, 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;
} catch (error: unknown) {
if (error instanceof FreebitError) {
if (error.isAuthError() && attempt === 1) {
this.logger.warn("Auth error detected, clearing cache and retrying");
this.authService.clearAuthCache();
continue;
}
if (!error.isRetryable() || attempt === config.retryAttempts) {
throw error;
}
}
if (attempt === config.retryAttempts) {
const message = getErrorMessage(error);
this.logger.error(
`Freebit JSON API request failed after ${config.retryAttempts} attempts`,
{
url,
error: message,
}
);
throw new FreebitError(`Request failed: ${message}`);
}
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
this.logger.warn(`Freebit JSON API request failed, retrying in ${delay}ms`, {
url,
attempt,
error: getErrorMessage(error),
});
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new FreebitError("Request failed after all retry attempts");
}
/**
* Make a simple request without authentication (for health checks)
*/
async makeSimpleRequest(endpoint: string): Promise<boolean> {
const config = this.authService.getConfig();
// Ensure proper URL construction - remove double slashes
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const url = `${baseUrl}${cleanEndpoint}`;
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: getErrorMessage(error),
});
return false;
}
}
/**
* Sanitize payload for logging (remove sensitive data)
*/
private sanitizePayload(payload: Record<string, unknown>): Record<string, unknown> {
const sanitized = { ...payload };
// Remove sensitive fields
const sensitiveFields = ["authKey", "oemKey", "password", "secret"];
for (const field of sensitiveFields) {
if (sanitized[field]) {
sanitized[field] = "[REDACTED]";
}
}
return sanitized;
}
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;
}
}