DEV Community

Taylor Wang
Taylor Wang

Posted on

The Free Model Caught Every Exception. My Monitoring Caught Nothing.

Have you ever watched a monitoring dashboard go quiet and felt relief instead of dread? That was my first mistake, because the silence meant my payment webhook was failing on every request. The error handling I had just "improved" was converting each failure into a clean 200 OK, and nobody could see the damage.

The dashboard lied by going quiet

Our payment webhook processed about 300 requests a day with a normal error rate of two or three percent. After I deployed a small change to the error handling, the error rate dropped to exactly zero, and the p99 latency stayed flat. That should have felt like a win, but payment records stopped growing, and customers emailed support about charges that never appeared.

The "robust" error handling the model wrote

Here is the original handler, which was not elegant but did have one virtue: when it failed, it failed loudly.

app.post('/webhooks/payment', async (req, res) => {
  const event = req.body;
  await processPayment(event);
  res.status(200).send('ok');
});
Enter fullscreen mode Exit fullscreen mode

If processPayment threw, the client received a 500, our error tracker captured the stack trace, and the payment provider retried the event. I asked a free model through MonkeyCode's free model access to make the handler more resilient, and it produced this:

app.post('/webhooks/payment', async (req, res) => {
  try {
    const event = req.body;
    await processPayment(event);
    res.status(200).send('ok');
  } catch (error) {
    console.error('Payment processing failed:', error.message);
    res.status(200).send('ok');
  }
});
Enter fullscreen mode Exit fullscreen mode

The model's reasoning was not entirely wrong, because webhook providers do interpret a 200 as "delivered, do not retry." The problem was that the model applied that logic to every failure, including transient database timeouts that should have been retried. It also logged only the error message, with no event ID, no stack trace, and no request context.

Why the tests did not save me

My unit tests mocked processPayment to resolve successfully, so the catch block never executed, and the tests asserted that the endpoint returned 200. The new code passed those tests perfectly, because I never wrote a test for the failure path. The model's summary said "handles errors gracefully," and I trusted that summary more than I trusted my own skepticism.

The debugging spiral

The first sign of trouble was a support ticket, not an alert, because no alert could fire when every request returned 200. I checked the logs and found lines like Payment processing failed: Connection timed out. That told me the error was real, but it gave me no way to correlate it with a specific event. I checked the payment provider's dashboard and saw every event marked as "delivered," because our server had confirmed receipt with a 200. I checked the database and found the last successful payment record from the day before my deploy, which finally connected the dots.

The fix: errors that mean something

What good is a catch block if it converts every failure into a false success? The corrected handler distinguishes between failures that deserve a retry and failures that do not, and it logs enough context to debug either case.

app.post('/webhooks/payment', async (req, res) => {
  const event = req.body;
  try {
    await processPayment(event);
    res.status(200).send('ok');
  } catch (error) {
    console.error('Payment processing failed', {
      eventId: event.id,
      customerId: event.customer,
      error: error.message,
      stack: error.stack,
      timestamp: new Date().toISOString()
    });
    if (error instanceof TransientError) {
      res.status(503).send('retry later');
    } else {
      res.status(400).send('invalid event');
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

A 503 tells the provider to retry, a 400 tells it to stop, and the structured log gives me the event ID to trace the failure. The error rate went back up, and this time I celebrated, because visible errors are the only kind you can fix.

The checklist I use now

  1. Read every catch block and ask what happens to the error: is it logged, rethrown, or converted into a success response?
  2. Check that error logs include identifiers like event IDs, customer IDs, and request IDs, not just messages.
  3. Verify that failure responses use the right status code for the client, 503 for retryable, 400 for permanent.
  4. Write at least one test for the failure path, and assert the response code and the log output, not just the absence of a crash.
  5. Look at the monitoring dashboard after a deploy and ask whether a real failure would actually be visible.

Where the free model and free server fit

The suggestion came from a free model I reached through MonkeyCode. The bug became visible only because I deployed it to MonkeyCode's free server option, where the database was small enough to inspect directly. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The lesson is not that free models write bad error handling; it is that any error handling deserves a test proving failures stay visible.

Who should not copy this pattern

If your webhook provider ignores retry semantics, distinguishing 503 from 400 will not help you; you may need an out-of-band retry queue instead. If your monitoring is minimal, returning a non-200 only moves the problem from invisible to loud, which is progress but not a fix. And if your error handling already works, do not let a model rewrite it just because the new version looks cleaner. Clean code that hides failures is worse than ugly code that surfaces them.

The next time a model offers to make your error handling more robust, ask what happens to the error after you catch it. If the answer is "nothing visible," the robustness is an illusion.

Top comments (0)