Building reliable LLM integrations means treating tool invocation as a failure-prone network boundary. Robust error handling function calling nodejs requires more than a try/catch around your fetch call; you need patterns for model errors, tool execution faults, and partial responses. This guide lays out an ordered path from raw API calls to a resilient agent loop that survives real production conditions.
1. Separate model errors from tool errors
The first mistake is catching a single exception and assuming it came from your code. The OpenAI-compatible completions endpoint can fail with 401, 429, 500, or return a tool_calls array with malformed arguments. Your local tool—say a SQL query or a CRM lookup—can throw a syntax error or a timeout. These demand different responses.
Define narrow error types so the rest of the system can branch:
class ModelAPIError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = 'ModelAPIError';
}
}
class ToolExecutionError extends Error {
constructor(public tool: string, message: string) {
super(message);
this.name = 'ToolExecutionError';
}
}
When you call the model, wrap the request and rethrow as ModelAPIError for non-2xx, and also catch AbortError from a timeout as a network-level fault. Leave tool errors for the execution phase. This separation makes retry logic honest: you retry the model call on 429/5xx, but never retry a tool just because the model hiccuped.
2. Validate arguments before execution
Models hallucinate parameter shapes. Never pass tool_call.function.arguments straight to your function. Parse and validate with a schema library like Zod:
import { z } from 'zod';
const WeatherArgs = z.object({
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180),
});
function parseArgs(raw: string) {
const json = JSON.parse(raw); // may throw SyntaxError
return WeatherArgs.parse(json); // may throw ZodError
}
A common pitfall: catching JSON.parse and returning a generic “invalid args” to the model without detail. The model can self-correct if you return the specific validation failure. Catch ZodError and format issues:
try {
const args = parseArgs(raw);
} catch (e) {
if (e instanceof z.ZodError) {
return `Argument validation failed: ${e.issues.map(i => i.path.join('.') + ' ' + i.message).join('; ')}`;
}
return 'Arguments were not valid JSON';
}
This keeps the error handling function calling nodejs loop alive instead of crashing the process. Add coercion and defaults where the model routinely omits optional fields—but never silently swallow a missing required field.
3. Retry only what is safe
Network errors and 429s are retryable. Validation errors and business-logic failures are not. Implement a scoped retry with capped exponential backoff and jitter:
async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (e) {
if (e instanceof ModelAPIError && [429, 500, 503].includes(e.status) && attempt < max) {
const base = Math.min(100 * 2 ** attempt, 2000);
const jitter = Math.random() * base * 0.3;
await new Promise(r => setTimeout(r, base + jitter));
attempt++;
continue;
}
throw e;
}
}
}
Tradeoff: retries amplify load during provider incidents. If you use a gateway that performs automatic fallback across providers, such as n4n.ai, the retry layer can be thinner because the gateway already shifts traffic when a provider is degraded. Still, surface 429s from the gateway to your caller.
Never retry tool calls unless they are idempotent. A sendEmail tool retried blindly duplicates messages. Mark tools with an idempotent flag and only retry those on transient infrastructure errors like a network reset.
4. Execute tools in isolation
In an agent loop, the model may request three tools in one response. One failing tool should not abort the others. Map over tool_calls and catch per call:
const results = await Promise.all(
toolCalls.map(async (call) => {
try {
const args = parseArgs(call.function.arguments);
const output = await dispatch(call.function.name, args);
return { callId: call.id, ok: true, output };
} catch (e) {
return { callId: call.id, ok: false, error: (e as Error).message };
}
})
);
Then feed each result back as a tool role message. The model sees the failure and can adapt. This isolation is the core of error handling function calling nodejs at the execution layer. If tools contend for rate limits, cap concurrency with a simple pool rather than firing all in parallel.
5. Return structured errors to the model
Do not throw from the tool dispatcher into the completion call. The model expects a tool message with content. Return a concise error string:
for (const r of results) {
messages.push({
role: 'tool',
tool_call_id: r.callId,
content: r.ok ? JSON.stringify(r.output) : `ERROR: ${r.error}`,
});
}
Pitfall: returning stack traces or internal SQL errors. That leaks secrets and confuses the model. Sanitize at the edge: map known error codes to safe messages. Also respect the model’s context limit—truncate large tool outputs or error payloads before sending them back.
6. Degrade gracefully when tools fail
If a critical tool—like a payment API—is down, continuing the loop may waste tokens or mislead the user. Detect consecutive tool failures and switch to a fallback path:
if (results.every(r => !r.ok)) {
messages.push({ role: 'assistant', content: 'I cannot complete this task because required tools are unavailable. Please try later.' });
break;
}
For non-critical tools, let the model proceed with partial data. The tradeoff is latency versus completeness; decide per tool via metadata. A circuit breaker that marks a tool unhealthy after N failures prevents repeated timeouts from stalling the whole agent.
7. Observe and meter
You cannot fix what you cannot see. Log every model call with status, token usage, and tool durations. If you meter per-token usage at the gateway, correlate those IDs with your internal trace ID.
console.log({
traceId,
model: resp.model,
usage: resp.usage,
toolErrors: results.filter(r => !r.ok).length,
});
Error handling function calling nodejs is incomplete without alerting on spikes in ModelAPIError or repeated ToolExecutionError for the same tool. Set thresholds, not just logs. Track p95 tool latency separately from model latency to spot downstream rot.
8. Test the unhappy paths
Most teams test the golden path where the model returns perfect arguments and the tool succeeds. Write tests that force ModelAPIError, malformed JSON, and tool timeouts.
import { describe, it, expect, vi } from 'vitest';
vi.mock('./dispatch', () => ({ dispatch: vi.fn().mockRejectedValue(new Error('db down')) }));
it('returns tool error without crashing loop', async () => {
const out = await runToolCall({ id: '1', function: { name: 'getUser', arguments: '{"id":1}' } });
expect(out.ok).toBe(false);
expect(out.error).toContain('db down');
});
Mock the model client to emit 429 then 200, verifying your retry honors the cap. These tests prove your error handling function calling nodejs code actually behaves when the network doesn’t.
Common pitfalls summary
- Catching all errors and returning them verbatim to the model (prompt injection risk).
- Retrying non-idempotent tools (duplicate side effects).
- Trusting
argumentswithout schema validation (runtime crashes). - Letting one tool failure abort the whole batch (poor resilience).
- Ignoring provider-specific rate limit headers (missed backoff signals).
- Skipping tests for failure modes because they are awkward to simulate.
Ship the separation of concerns first: distinct error types, validation at the boundary, scoped retries, isolated execution, sanitized returns. That ordered path turns a fragile script into a production agent.