DEV Community

Cover image for Your-Error-Handling-is-a-Mess-and-Its-Costing-You-💸
member_f8c307c5
member_f8c307c5

Posted on

Your-Error-Handling-is-a-Mess-and-Its-Costing-You-💸

GitHub Home

Your Error Handling is a Mess, and It's Costing You 💸

I still remember the bug that kept me up all night. A payment callback endpoint, when handling a rare, exceptional status code from a third-party payment gateway, had a .catch() that was accidentally omitted from a Promise chain. The result? No logs, no alerts, and the service itself didn't crash. It just "silently" failed. That user's order status was forever stuck on "processing," and we were completely unaware. It wasn't until a week later during a reconciliation that we discovered hundreds of these "silent orders," resulting in tens of thousands of dollars in losses. 💸

The lesson was painful. It made me realize that in software engineering, we probably spend less than 10% of our time on the happy path. The other 90% of the complexity comes from how to handle all sorts of expected and unexpected errors gracefully and robustly. And the quality of a framework is largely reflected in how it guides us to face this "world of errors."

Many frameworks, especially the "flexible" ones in dynamic languages, have a philosophy on error handling that can almost be described as "laissez-faire." They give you a thousand ways to make a mistake, but only one way to do it right, which requires extreme discipline.

Callback Hell and Swallowed Promises: The Agony of Error Handling in JavaScript

In the world of Node.js, we've gone through a long evolution of battling with errors.

Phase 1: Callback Hell

The older generation of Node.js developers still remembers the fear of being dominated by the "pyramid of doom."

function processOrder(orderId, callback) {
  db.findOrder(orderId, (err, order) => {
    if (err) {
      // Error handling point 1
      return callback(err);
    }
    payment.process(order, (err, result) => {
      if (err) {
        // Error handling point 2
        return callback(err);
      }
      inventory.update(order.items, (err, status) => {
        if (err) {
          // Error handling point 3
          return callback(err);
        }
        callback(null, status); // Success!
      });
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

This "error-first" callback style is viable in theory. But as business logic gets more complex, the code extends infinitely to the right, forming a "pyramid of death" that is difficult to maintain. You have to remember to check the err object in every single callback. A single oversight, and the error gets "swallowed."

Phase 2: The Redemption of Promises and New Pitfalls

Promise rescued us from callback hell. We could use .then() and .catch() to build a flatter, more readable asynchronous chain.

function processOrder(orderId) {
  return db
    .findOrder(orderId)
    .then((order) => payment.process(order))
    .then((result) => inventory.update(result.items))
    .catch((err) => {
      // Centralized error handling point
      console.error('Order processing failed:', err);
      // But here, you must remember to re-throw the error,
      // or the caller will think it succeeded.
      throw err;
    });
}
Enter fullscreen mode Exit fullscreen mode

This is much better! But new problems arose. If you forget to return the next Promise in a .then(), or forget to re-throw the error in a .catch(), the chain will continue to execute in a way you didn't expect. The error, once again, is "silently" swallowed.

Phase 3: The Elegance of async/await and Its Final Disguise

async/await allows us to write asynchronous code in a seemingly synchronous way, which is a godsend.

async function processOrder(orderId) {
  try {
    const order = await db.findOrder(orderId);
    const result = await payment.process(order);
    const status = await inventory.update(result.items);
    return status;
  } catch (err) {
    console.error('Order processing failed:', err);
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

This looks almost perfect, doesn't it? But it still relies on the programmer's "conscientiousness." You have to remember to wrap all potentially failing asynchronous calls in a try...catch block. What if you forget to await a function that returns a Promise? The error within that function will never be caught by this try...catch.

The problem with JavaScript is that an error is a value that can be easily ignored. null and undefined can roam your code like ghosts. You need to rely on strict conventions, Linter tools, and personal discipline to ensure every error is handled correctly. And that, precisely, is unreliable.

The Result Enum: When the Compiler Becomes Your Most Reliable Error-Handling Partner

Now, let's enter the world of Rust and hyperlane. Here, the philosophy of error handling is completely different. At the core of the Rust language, there is an enum called Result<T, E>.

enum Result<T, E> {
   Ok(T),  // Represents success and contains a value
   Err(E), // Represents failure and contains an error
}
Enter fullscreen mode Exit fullscreen mode

This design is simple yet profound. It means that a function that might fail must return one of these two states. It's no longer a value that could be null, or a Promise that you need to .catch() somewhere else. It's a complete type that contains all possibilities.

Most importantly, the compiler will force you to handle the Err case. If you call a function that returns a Result but don't handle its Err branch, the compiler will give you a warning or even an error. You cannot "accidentally" ignore an error.

Let's see what the code would look like in hyperlane's service layer:

// in a service file
pub fn process_order(order_id: &str) -> Result<Status, OrderError> {
    let order = db::find_order(order_id)?; // `?` operator: if it fails, return Err immediately
    let result = payment::process(order)?;
    let status = inventory::update(result.items)?;
    Ok(status) // Explicitly return success
}
Enter fullscreen mode Exit fullscreen mode

See that ? operator? It's the essence of Rust's error handling. It's basically saying: "Call this function. If it returns Ok(value), take the value out and continue. If it returns Err(error), immediately return that Err(error) from the current function."

This pattern transforms the logic that required try...catch in JavaScript into an extremely concise, clear, and compiler-guaranteed chain of calls. An error is no longer an exception to be "caught," but an expected branch in the data flow that is handled gracefully.

panic_hook: The Last Line of Defense

Of course, there are always errors we can't anticipate, which are panics. For example, array out of bounds, integer overflow, etc. In many frameworks, an unhandled panic will cause the entire thread or even the process to crash.

But hyperlane provides an elegant "last line of defense"—the panic_hook. We've seen it in previous articles:

async fn panic_hook(ctx: Context) {
    let error: Panic = ctx.try_get_panic().await.unwrap_or_default();
    let response_body: String = error.to_string();
    eprintln!("{}", response_body); // Log the detailed error

    // Return a standard, safe 500 error response to the client
    let _ = ctx
        .set_response_status_code(500)
        .await
        .set_response_body("Internal Server Error")
        .await
        .send()
        .await;
}

// Register it in the main function
server.panic_hook(panic_hook).await;
Enter fullscreen mode Exit fullscreen mode

This hook can catch any panic that occurs during request processing. It prevents the server from crashing directly and allows you to log detailed error information for post-mortem analysis, while returning a friendly error page to the client instead of a disconnected connection. This is an extremely responsible and robust design.

Stop Praying for Code Without Errors, Embrace Errors from the Start

Good error handling isn't about stuffing try...catch blocks in every corner of your code. It's about having a system, at the language and framework level, that provides you with a mechanism to make "failure" a predictable, first-class citizen of your program's flow.

Rust's Result enum forces you to confront every possible failure, and hyperlane's architecture and hook system provide you with elegant patterns for handling them. It elevates error handling from a matter of "developer discipline" to a "compiler guarantee."

So, if you're still struggling with messy error handling logic and fearing those "silent" failures, the problem might not be that you're not trying hard enough. It might be that the tools you've chosen didn't prioritize "robustness" from the very beginning. It's time to choose a partner that can fight alongside you to face this imperfect world.

GitHub Home

Top comments (0)