Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims.errors.ts
Hagernesh 0999bc0b8b fix(eims): a mapper failure after reservation also orphaned the block
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.
2026-08-17 12:09:25 +00:00

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"}`);
}