Use a strict boundary in Node.js: treat every moderation response as untrusted bytes until it has passed transport checks, JSON parsing, schema validation, and a domain decision. For supplier invoices entering a customer-support queue, that sequence is more useful than a clever retry loop. It preserves latency for ordinary invoices while sending ambiguous fields to review instead of quietly inventing values.
The constraint that changes the design is quality versus latency. A malformed response is not a negative moderation result. It is an unavailable decision. Conflating those states can let an invalid invoice classification flow into downstream routing.
No guesswork.
How should Node.js handle malformed JSON from a moderation API response?
At the adapter boundary. Keep the rest of the application dependent on a small typed result, not on a provider-shaped object. The adapter should record status, content type, request correlation data, and a bounded body sample before it attempts parsing. Do not log the full invoice or raw user content by default; support tickets often contain personal and financial data.
A useful result has three paths: an accepted decision, a rejected decision, and an indeterminate failure. Only the first two may drive an automated action. A truncated body, an HTML error page, an empty body, or valid JSON with the wrong fields belongs in the third path. Keep it typed.
That boundary is the whole point. If the adapter returns a boolean, callers will eventually treat every false value as a deliberate rejection, even when the response was a gateway page or an interrupted stream. If it returns the provider's object, every caller will grow a slightly different parser, and the invoice workflow will have several incompatible definitions of success. A discriminated result forces the caller to acknowledge uncertainty. It also gives telemetry a stable vocabulary: the queue can distinguish invalid-json from invalid-shape, while the support UI can explain that the invoice is waiting for a decision rather than claim that its content was rejected. This is more code at the edge and less ambiguity everywhere else.
Here is the smallest implementation I would put between fetch and invoice-field extraction. The schema is deliberately boring. Boring interfaces are easier to benchmark and harder to misread.
type ModerationDecision = {
kind: "decision";
allowed: boolean;
invoiceId: string;
} | {
kind: "indeterminate";
invoiceId: string;
reason: "transport" | "invalid-json" | "invalid-shape";
};
function parseModerationBody(
invoiceId: string,
body: string,
): ModerationDecision {
let value: unknown;
try {
value = JSON.parse(body);
} catch {
return { kind: "indeterminate", invoiceId, reason: "invalid-json" };
}
if (
typeof value !== "object" ||
value === null ||
typeof (value as { allowed?: unknown }).allowed !== "boolean"
) {
return { kind: "indeterminate", invoiceId, reason: "invalid-shape" };
}
return {
kind: "decision",
allowed: (value as { allowed: boolean }).allowed,
invoiceId,
};
}
JSON.parse only answers whether the text is syntactically valid JSON. It does not prove that the value is an object, that allowed is a boolean, or that the decision refers to the invoice being processed. In production code I would replace the small type guard with a runtime schema library or a JSON Schema validator, but the boundary contract stays the same.
Why does a retry make this worse?
Because retrying all failures spends latency on failures that cannot improve. A 429 or a transient 5xx can be retryable according to the service contract. An invalid JSON body may be a proxy response, a truncation, or a contract regression. It deserves evidence and a bounded retry policy, not an unconditional loop.
For invoice triage, I use separate budgets. The moderation call gets a short deadline. One retry is reasonable for a classified transient transport failure. A parse failure goes to an indeterminate queue after the response is captured with redaction. The extractor can continue only with fields whose own validation passed. That is a hard quality gate.
The queue payload should contain an invoice identifier, failure class, attempt count, and a hash or reference to protected diagnostics. It should not contain an unbounded response body. This makes replay possible without turning observability into a second data store.
Measure the boundary.
A practical test matrix has at least these cases: a valid decision, whitespace around valid JSON, an empty body, truncated JSON, valid JSON with a missing field, a non-boolean field, a non-2xx response, and a response that exceeds the body limit. I test them at the adapter level and then run one end-to-end case to verify that an indeterminate result cannot auto-approve an invoice.
The long case is worth spelling out: a supplier invoice arrives with OCR text, a proxy returns an HTML timeout page with status 200, and a naive JSON.parse call throws after the application has marked the ticket as processed. A bounded capture classifies invalid JSON, leaves the invoice available for replay, and keeps the support queue honest. The extra classification work adds a little review latency; silently treating the empty result as safe is faster only until someone reconciles the invoice by hand.
How do quality and latency trade places?
Measure them separately. A single average hides the useful failure boundary. I would emit counters for parse failures, shape failures, transport failures, and human-review outcomes; then measure p50 and p95 decision latency for successful requests and for the indeterminate path. The operational question is not “did the API return JSON?” It is “how often did an invoice receive a trustworthy decision within its deadline?”
Structured output constraints can reduce the distance between the requested shape and the received shape, but they are not a replacement for checking the response at the client boundary. The client still owns timeouts, status handling, size limits, cancellation, and redaction. A declared schema also needs domain checks: an invoice total cannot be accepted merely because it is a number, and a supplier identifier cannot be accepted merely because it is a string.
The limitation is real: strict validation rejects ambiguous work and increases review volume, so it is a poor fit for a workflow that cannot tolerate a queue or a human fallback. A permissive parser has lower latency, but it transfers that uncertainty to accounting and routing.
I benchmark with representative invoice batches rather than a synthetic loop of tiny strings. The slow path matters: large OCR text, a busy support queue, and a reviewer waiting on a replay all change the result. A 50 ms parsing improvement is irrelevant if the retry policy adds seconds to every malformed response.
What I would change at scale
At higher volume, I would add a circuit breaker around repeated transport failures, an idempotency key for each invoice attempt, and a replay worker that uses the same parser version recorded with the original event. Parser versions matter. When the contract changes, a later replay should not silently reinterpret an old response.
I would also split moderation from extraction in the workflow. Moderation answers whether the submitted content may proceed. Extraction answers whether invoice fields meet their own schema and business rules. One malformed moderation payload should not be converted into an empty invoice object, and one valid moderation decision should not be treated as proof that extraction succeeded.
The decision rule is straightforward: accept automation only when transport, syntax, shape, and domain validation all pass inside the latency budget. Otherwise preserve the invoice, mark the decision indeterminate, and make the failure observable. That is a small amount of glue. It buys a much clearer system boundary.
References
- RFC 8259, The JavaScript Object Notation (JSON) Data Interchange Format: https://www.rfc-editor.org/rfc/rfc8259
- MDN,
JSON.parse(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse - OpenAI, Structured Outputs guide: https://platform.openai.com/docs/guides/structured-outputs
- WHATWG Fetch Standard: https://fetch.spec.whatwg.org/
Top comments (0)