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.