mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
toEimsInvoice/buildEimsContext sat outside the try/catch that calls settleFailure — reservation happens (TX1), then request-building ran unguarded, then submit() was the only thing actually wrapped. Any exception during mapping (a validation error like an unmapped buyer country, or a bug) skipped settleFailure entirely and left the reservation permanently held: exactly the live incident just seen — register 500'd, and every subsequent attempt on any invoice 409'd 'already in flight' until manually resolved. Fix: the try block now starts right after reserve(), covering request-building and submit() both. settleFailure's determinism check is generalized to match — any error that is not an EimsApiException is pre-wire and safe to release, not just EimsConfigException (still labeled CONFIG; everything else pre-wire is now labeled the new LOCAL kind). This is exhaustive by construction: every error that actually touches the wire is already normalized to EimsApiException inside EimsClientService.send()'s own catch, so nothing outside that can be ambiguous.
92 lines
3.3 KiB
TypeScript
92 lines
3.3 KiB
TypeScript
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<string, unknown>;
|
|
const safe: Record<string, unknown> = {};
|
|
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"}`);
|
|
}
|