import { BadGatewayException, ServiceUnavailableException } from "@nestjs/common"; import { AxiosError } from "axios"; import { EimsErrorResponse } from "./eims.types"; export type EimsFailureKind = | "NETWORK" | "TIMEOUT" | "SCHEMA_VALIDATION" | "AUTH" | "FORBIDDEN" | "RULE_VALIDATION" | "SERVER" | "UNKNOWN" | "CONFIG" | "LOCAL"; /** Raised when EIMS is disabled or its credential files are unusable. */ export class EimsConfigException extends ServiceUnavailableException { constructor(message: string) { super({ code: "EIMS_NOT_CONFIGURED", message }); } } /** * A failed EIMS call. Carries only the gateway's own error reporting — never the request body, * signature, certificate, bearer token or any configured secret. */ export class EimsApiException extends BadGatewayException { constructor( readonly kind: EimsFailureKind, message: string, readonly httpStatus?: number, readonly details?: EimsErrorResponse, ) { super({ code: `EIMS_${kind}`, message }); } } const SAFE_KEYS = ["message", "statusCode", "code", "details", "body"] as const; /** * Keep only the gateway's error-reporting fields. Anything else a response might carry — an echoed * request, a token, a signature — is dropped before it can reach a log or an exception payload. */ export function redactEimsBody(data: unknown): EimsErrorResponse | undefined { if (!data || typeof data !== "object") return undefined; const source = data as Record; const safe: Record = {}; for (const key of SAFE_KEYS) { if (source[key] !== undefined) safe[key] = source[key]; } return Object.keys(safe).length > 0 ? (safe as EimsErrorResponse) : undefined; } const kindFor = (status: number): EimsFailureKind => { if (status === 400) return "SCHEMA_VALIDATION"; if (status === 401) return "AUTH"; if (status === 403) return "FORBIDDEN"; if (status === 406) return "RULE_VALIDATION"; if (status >= 500) return "SERVER"; return "UNKNOWN"; }; /** First error line the gateway gives us, whichever shape it used. */ const describe = (body: EimsErrorResponse | undefined): string => { if (!body) return "no error body"; const detail = body.details?.find((d) => d.errorMessage)?.errorMessage; return [body.message, body.code && `code=${body.code}`, detail].filter(Boolean).join(" ") || "no error body"; }; /** * Normalise anything thrown by an EIMS HTTP call into an `EimsApiException`. `operation` is a * short label such as `"login"` or `"POST /v1/register"` — never a payload. */ export function toEimsApiException(err: unknown, operation: string): EimsApiException { if (err instanceof EimsApiException) return err; if (err instanceof AxiosError) { if (err.code === "ECONNABORTED" || err.code === "ETIMEDOUT") { return new EimsApiException("TIMEOUT", `EIMS ${operation} timed out`); } if (!err.response) { return new EimsApiException("NETWORK", `EIMS ${operation} could not reach the gateway (${err.code ?? "no code"})`); } const status = err.response.status; const body = redactEimsBody(err.response.data); return new EimsApiException(kindFor(status), `EIMS ${operation} failed (${status}): ${describe(body)}`, status, body); } return new EimsApiException("UNKNOWN", `EIMS ${operation} failed: ${(err as Error)?.message ?? "unknown error"}`); }