Error Handling: Naive vs Robust Code Prompts
Kestrel FX returns a 429 for roughly forty minutes every Tuesday night, and Tomas only learned this after the nightly reconciliation job started writing zero-rate rows into the ledger. The job runs at 02:15 UTC, pulls currency rates for about 40,000 transactions, and matches them against the Postgres table recon_entries. It had a single try/catch around the whole thing, so every Tuesday the failure was caught, logged as the word “error,” and forgotten.
This article puts two prompts side by side on that job. The first asks for error handling and gets a wrapper that hides the failure. The second asks for a design that survives failure and gets retries, backoff, and a dead-letter path. You will see both prompts, why the second changes the code you receive, and a reusable skeleton.
The Patch That Silenced the Alert, Not the Failure
Wrapping a whole job in one try/catch is the most common mistake in AI-assisted backend work, because it looks like diligence and behaves like a mute button. When the rate call fails, the catch swallows the error, the job exits cleanly, and the scheduler reports success. The real damage is downstream: reconciliation silently skips a night, the ledger drifts, and finance finds a mismatch three weeks later with no idea which run was bad.
Error handling is not a wrapper. It is a set of decisions about each failure mode: what kind of error is this, is it worth retrying, how long should I wait, what happens if it keeps failing, and how do I avoid writing a half-finished batch. A prompt that names those decisions gets a different piece of code than a prompt that says “add error handling.”
The cost scales with the job’s reach. Tomas’s job touches a paid API with a rate limit, a database with a unique constraint, and a scheduler that runs unattended at 02:15. Every one of those is a place where naive handling turns a loud failure into a quiet, expensive one.
Two Prompts: Wrap It in try/catch vs Design for Failure
Both prompts below target the same function. Read what each one gives the model to work with.
Add error handling to this function. It pulls rates from the Kestrel FX API
and writes them into the recon_entries table.
async function reconcile() {
try {
const rates = await kestrel.getRates();
const rows = await db.transactions.pending();
for (const row of rows) {
row.rate = rates[row.currency];
await db.recon_entries.insert(row);
}
} catch (e) {
console.log('error');
}
}
Make it more robust.“More robust” is not actionable, and the model has no idea which failures matter. It usually widens the try/catch, adds a few console lines, and calls it done, which is exactly the bug that started this. Now the second request.
Role: senior backend engineer. Do not rewrite the function yet.
Context
- Job: nightly FX reconciliation, runs 02:15 UTC via the scheduler.
- Provider: Kestrel FX REST API. Pushes about 40,000 transaction rows and
matches them against the Postgres table recon_entries.
- Known failure: Kestrel returns HTTP 429 for about 40 minutes most Tuesday
nights. Timeout budget is 3 seconds per call.
- The job must never write a partial batch, and a rerun must not duplicate
rows that were already written.
Ask, in this order
1) List the failure modes for this job, grouped as transient, permanent, and
ambiguous, and say for each whether retrying is safe.
2) For the transient ones, specify retry count, exponential backoff with
jitter, and the total time budget.
3) Recommend how to keep a rerun idempotent against recon_entries.
4) Only after I confirm the design, write the updated function.
Constraints
- No new paid dependencies.
- Every retryable call needs a timeout of 3 seconds.
- Failures that exhaust retries must land in a dead-letter table, not a log
line, so the Tuesday gap is visible to the on-call engineer.The first prompt asks for a synonym of “try/catch.” The second asks for a failure taxonomy and a recovery policy before a single line of code is written. That reordering, design first and code second, is what changes the output.
Why the Robust Prompt Produces Durable Code
- It separates error classes. A Tuesday 429 is transient; a malformed currency code is permanent. Retrying the first is correct and retrying the second wastes the time budget. Asking for the groups forces that distinction.
- It carries the real numbers. The 02:15 run, the 40-minute 429 window, the 3-second timeout, and the 40,000 rows tell the model how much backoff budget the job can afford without running into the morning.
- It names the idempotency requirement. Tomas’s reruns must not double-insert, so the model proposes a unique key on the transaction plus currency, and an upsert instead of a plain insert.
- It makes partial writes impossible. Stating that the job must never write a partial batch pushes the model toward batching the whole run in one transaction rather than inserting row by row as it goes.
- It routes exhausted failures to a dead-letter table. A Tuesday gap becomes a queryable row the on-call engineer can act on, which is the opposite of a log line nobody reads.
- It stops at a design step. “Only after I confirm, write the code” keeps you in control of the architecture instead of accepting a rewrite on faith.
The same evidence-first habit shows up in any debugging request, and our side-by-side on debug prompts: wrong vs right covers the diagnosis half of this pattern, while a broader set of coding comparisons lives in coding prompt examples compared.
Name the Failure Modes in the Prompt
Most weak error-handling prompts fail because they never say what can go wrong. Name these before you ask for code, and the request gets specific on its own.
- Transient, retry is safe. HTTP 429 and 503, network timeouts, and deadlocks. Retry with backoff and a total time budget.
- Permanent, do not retry. 400 and 401 responses, malformed payloads, and unknown currency codes. Route straight to the dead-letter table.
- Ambiguous, retry carefully. A timeout after the request left but before the response arrived. Safe only with an idempotency key.
- Partial-write risk. Any multi-step write that can fail halfway. Wrap it so the batch is all or nothing.
- Cost guard. A paid API with a rate limit, where a retry storm creates more 429s than it fixes.
If you are fine-tuning how the assistant reads your code, the settings in ChatGPT coding settings are worth a look before you paste a large file.
A Robustness Prompt You Can Reuse
Fill the blanks and keep the order. The design-first step is what stops you from accepting a wrapper as a solution.
Role: senior {{stack}} engineer. Do not rewrite the code yet.
Context
- Job: {{what it does}}, runs {{schedule}}.
- Dependencies: {{paid API, database, queue, and known limits}}.
- Known failures: {{the specific errors you have seen, with frequency}}.
- Requirements: {{never write partial data, safe reruns, visibility for on-call}}.
Ask, in this order
1) List the failure modes, grouped as transient, permanent, and ambiguous,
and say whether retrying each is safe.
2) For transient ones, specify retry count, exponential backoff with jitter,
and a total time budget.
3) Recommend how to make a rerun idempotent.
4) Only after I confirm the design, write the updated code.
Constraints
- No new paid dependencies.
- Every retryable call needs a timeout of {{n}} seconds.
- Exhausted failures go to a dead-letter table, not a log line.Run the design step first, read the taxonomy, and correct anything the model misjudged about your provider. Only then ask for code. That two-step rhythm is the difference between a job that hides a Tuesday outage and a job that hands you a row to fix on Wednesday.
Sources & Notes
Tomas, Kestrel FX, and the 02:15 reconciliation job are illustrative; no real service or vendor is described. Retry and backoff patterns here follow common resilience practice, including exponential backoff with jitter and idempotency keys for safe reruns. Rate limits and retry semantics differ by provider, so always check the current documentation for the API you call before you set a retry budget, and test the dead-letter path before you rely on it in production.
