An AI HTTP response gate: separate HTTP failures, invalid JSON and review candidates

A small flow for the boundary after an AI HTTP request: separate transport/HTTP failures, invalid output, and a structurally valid candidate. A 200 response by itself should not trigger an action.

Disclosure: I work on Tokatlas, a multi-model API platform. This AI-assisted example is provider-independent and uses synthetic support messages. It contains no API keys or live calls.

Import the demonstration

In Node-RED, use Menu → Import → Clipboard, paste the JSON below, and import into a new flow. Deploy, then press the four Inject buttons individually. The only outputs are Debug nodes showing payload, not the complete message.

[{"id":"ta_gate_tab","type":"tab","label":"AI response review gate","disabled":false,"info":"Synthetic fixtures only; no network requests."},{"id":"ta_valid","type":"inject","z":"ta_gate_tab","name":"200 valid candidate","props":[{"p":"payload","v":"{\"category\":\"billing\",\"evidence\":\"charged twice\"}","vt":"str"},{"p":"statusCode","v":"200","vt":"num"},{"p":"sourceText","v":"I was charged twice.","vt":"str"},{"p":"requestId","v":"fixture-valid","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":170,"y":80,"wires":[["ta_gate"]]},{"id":"ta_invalid","type":"inject","z":"ta_gate_tab","name":"200 malformed JSON","props":[{"p":"payload","v":"not JSON","vt":"str"},{"p":"statusCode","v":"200","vt":"num"},{"p":"sourceText","v":"I was charged twice.","vt":"str"},{"p":"requestId","v":"fixture-invalid","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":170,"y":140,"wires":[["ta_gate"]]},{"id":"ta_rate","type":"inject","z":"ta_gate_tab","name":"429 response","props":[{"p":"payload","v":"rate limited","vt":"str"},{"p":"statusCode","v":"429","vt":"num"},{"p":"sourceText","v":"I was charged twice.","vt":"str"},{"p":"requestId","v":"fixture-rate","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":170,"y":200,"wires":[["ta_gate"]]},{"id":"ta_wrong","type":"inject","z":"ta_gate_tab","name":"200 wrong evidence","props":[{"p":"payload","v":"{\"category\":\"billing\",\"evidence\":\"refund approved\"}","vt":"str"},{"p":"statusCode","v":"200","vt":"num"},{"p":"sourceText","v":"I was charged twice.","vt":"str"},{"p":"requestId","v":"fixture-wrong","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":170,"y":260,"wires":[["ta_gate"]]},{"id":"ta_gate","type":"function","z":"ta_gate_tab","name":"HTTP + candidate gate","func":"const status = Number(msg.statusCode);\nconst id = typeof msg.requestId === 'string' ? msg.requestId : 'example';\nconst out = (reason, candidate) => ({payload: {requestId: id, reason, ...(candidate ? {candidate} : {})}});\nif (!Number.isInteger(status) || status < 200 || status >= 300) {\n    return [null, null, out(Number.isInteger(status) ? `http_${status}` : 'transport_or_missing_status')];\n}\nlet c;\ntry { c = typeof msg.payload === 'string' ? JSON.parse(msg.payload) : msg.payload; }\ncatch (_) { return [null, out('invalid_json'), null]; }\nif (!c || Array.isArray(c) || typeof c !== 'object' ||\n    Object.keys(c).sort().join(',') !== 'category,evidence' ||\n    !['billing','technical','other'].includes(c.category) ||\n    typeof c.evidence !== 'string' || !c.evidence.trim() ||\n    typeof msg.sourceText !== 'string' || !msg.sourceText.includes(c.evidence)) {\n    return [null, out('schema_or_evidence_mismatch'), null];\n}\nreturn [out('candidate_only', {category:c.category,evidence:c.evidence}), null, null];\n","outputs":3,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":440,"y":160,"wires":[["ta_candidate"],["ta_review"],["ta_error"]]},{"id":"ta_candidate","type":"debug","z":"ta_gate_tab","name":"candidate (payload only)","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","x":730,"y":100,"wires":[]},{"id":"ta_review","type":"debug","z":"ta_gate_tab","name":"review (payload only)","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","x":730,"y":160,"wires":[]},{"id":"ta_error","type":"debug","z":"ta_gate_tab","name":"error (payload only)","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","x":730,"y":220,"wires":[]}]

The intended routes are:

Inject button Output Meaning
200 valid candidate 1: candidate Shape and exact-excerpt checks pass; still needs semantic review
200 malformed JSON 2: review A successful HTTP status did not contain parseable candidate JSON
429 response 3: error Rate-limit response; no automatic retry loop
200 wrong evidence 2: review The proposed evidence is not in the source text

The Function node has three outputs. Here is the gate in readable form:

const status = Number(msg.statusCode);
const id = typeof msg.requestId === 'string' ? msg.requestId : 'example';
const out = (reason, candidate) => ({payload: {requestId: id, reason, ...(candidate ? {candidate} : {})}});
if (!Number.isInteger(status) || status < 200 || status >= 300) {
    return [null, null, out(Number.isInteger(status) ? `http_${status}` : 'transport_or_missing_status')];
}
let c;
try { c = typeof msg.payload === 'string' ? JSON.parse(msg.payload) : msg.payload; }
catch (_) { return [null, out('invalid_json'), null]; }
if (!c || Array.isArray(c) || typeof c !== 'object' ||
    Object.keys(c).sort().join(',') !== 'category,evidence' ||
    !['billing','technical','other'].includes(c.category) ||
    typeof c.evidence !== 'string' || !c.evidence.trim() ||
    typeof msg.sourceText !== 'string' || !msg.sourceText.includes(c.evidence)) {
    return [null, out('schema_or_evidence_mismatch'), null];
}
return [out('candidate_only', {category:c.category,evidence:c.evidence}), null, null];

Place it after an HTTP Request node

Before the request, preserve your synthetic input in msg.sourceText and assign a stable job identifier to msg.requestId. Configure the HTTP Request node for the method, URL, authentication and body required by your provider, returning the response as text.

The gate expects msg.statusCode and candidate JSON in msg.payload. If your endpoint returns an envelope, add an adapter first. For a chat-completions-style response, an example adapter is:

const status = Number(msg.statusCode);
if (Number.isInteger(status) && status >= 200 && status < 300) {
    try {
        const body = typeof msg.payload === 'string' ? JSON.parse(msg.payload) : msg.payload;
        const content = body?.choices?.[0]?.message?.content;
        msg.payload = typeof content === 'string' ? content : null;
    } catch (_) {
        msg.payload = null;
    }
}
return msg;

Match the adapter to your endpoint's actual response format; it is not a universal response parser. The prompt should ask for exactly category (billing, technical or other) and evidence (a verbatim source excerpt). Treat source text as data, not instructions.

For catchable request failures, add a Catch node scoped to the HTTP Request node and connect it to a separate Function:

return {payload: {requestId: msg.requestId || 'unknown', reason: 'request_error'}};

Connect that Function to the error-review destination. Do not assume Catch will handle every non-2xx response; that is why the status gate is separate. If your node configuration can emit both a normal failure message and a Catch event, deduplicate by requestId before creating a review task.

Keep the action boundary explicit

Output 1 means candidate, not correct or approved. An exact quote can still support the wrong category. Send it to a review queue before refunds, account changes, or other consequential actions. Outputs 2 and 3 should also have an owned review path, not disappear into Debug when you move beyond this demo.

For production, replace Debug with a durable queue, redact sensitive input, keep credentials out of exported flows, and add bounded retries only where the provider's semantics allow them. A timeout does not establish that a request was never processed or billed. Never connect the error branch straight back to the request node with an unlimited loop.

Reference: Node-RED error handling.

How do you keep HTTP errors separate from valid-but-wrong model outputs in your flows? A single error queue is simpler, but distinct reasons make manual triage much easier.

same split here, statusCode check first and only 2xx reaches the JSON parse, so a 429 body never counts as invalid JSON. anything that passes schema still goes to review, valid shape isnt enough to act on it.