Blog

Errors as Design: Exceptions, Error Values and Result

How C# and Java exceptions, Go’s error values and Rust’s Result carry one failure through a service, what each compiler checks, what failure costs, measured, and where to handle errors, log them and turn them into responses.

Every function that talks to a disk, a network or a user can fail. How a language makes you deal with that shapes every layer of a service: which signatures change when a new failure appears, what a caller is forced to look at, what gets logged, and what the customer sees.

This part compares the four models in our languages: unchecked exceptions in C#, checked and unchecked exceptions in Java, error values in Go, and Result in Rust. We send the same failure through the same three layers in each, record what every compiler and runtime actually does in the cases people argue about, measure what a failure costs, and then turn to the design questions that matter more than the mechanism: which errors to handle, which to let crash, and where the boundary is.

Try this first

An order service saves each order to a database. Someone wrapped the call like this, “so a database hiccup can’t crash the service”:

try {
    repository.save(order);
} catch (Exception e) {
    log.warn("could not save order", e);
}
return Response.created(order.id());

The database is down for five minutes during a sale. What does each customer see? What does the business find out, and when? Write your answers down before reading on.

Kinds of failure, before kinds of mechanism

The customers in that example all saw “order placed”. The orders were never saved, and the only trace is a warning in a log. The handler didn’t crash, which was the goal, and it did something worse: it carried on as if nothing had failed.

That pattern isn’t rare. Ding Yuan and colleagues studied 198 randomly sampled, user-reported failures in Cassandra, HBase, HDFS, Hadoop MapReduce and Redis (OSDI 2014). Of the 48 failures with catastrophic consequences, “almost all (92%)” were “the result of incorrect handling of non-fatal errors explicitly signaled in software”. In 25%, the error was ignored, and “an error handler that only logs the error is also considered as ignoring the error.” The errors had been noticed: “In all but one case, the errors were checked by the developers.” The paper suggests one reason is that “the Java compiler forces developers to catch all the checked exceptions”, and the developers were “often simply sloppy in handling these errors”. Forcing a catch didn’t make the handling right.

So the first design question isn’t “exceptions or return values”. It’s what kind of failure you’re looking at. Several people have drawn the same line in different words:

Kind Examples What to do Who said it
Expected failure in the world file missing, network timeout, card declined, invalid input handle it: retry, fall back, or report it to the caller in terms they understand Lippert: “exogenous”; Rust Book: “recoverable”
Bug index out of range, null where the code assumed a value, broken invariant don’t catch it; stop, and let something outside restart cleanly Duffy: “A bug is a kind of error the programmer didn’t expect”; Lippert: “boneheaded”
Fatal out of memory, stack overflow nothing useful to do in-process Lippert: “fatal”
Awkward API int.Parse throwing on ordinary bad input use a non-throwing variant (TryParse) Lippert: “vexing”

Eric Lippert, then on the C# compiler team, gave the four buckets in “Vexing exceptions” (2008). His advice on bugs: “You should not catch them; doing so is hiding a bug in your code.” Joe Duffy, describing the error model of Microsoft Research’s Midori operating system (2016), put it more strongly: “Proceeding in the face of a bug is dangerous when you’re trying to build a robust system.” Midori tore the process down on any bug, a policy it called abandonment, and in its code, “Abandonment-based errors outnumbered recoverable errors by a ratio approaching 10:1.”

Joe Armstrong’s slogan for Erlang, “Let it crash”, is the same idea, and it’s often quoted without its other half. His thesis (2003) lists “Let some other process do the error recovery” first, and gives opening a missing file as a case where the programmer “might decide that this is not an error” and handle it. Crashing works when a supervisor restarts a small, isolated piece. It doesn’t mean “handle nothing”.

Explain it like I’m ten

A baker runs out of flour halfway through the morning. That’s annoying, but it happens: the baker phones the supplier, and tells customers the bread will be an hour late.

Now suppose the recipe card has a typo and says “bake for 400 minutes”. If the baker follows the card, every loaf burns. The right move isn’t to keep baking and hope. It’s to stop, fix the card, and start that batch again.

And the worst baker of all writes “something went wrong” on a note, drops it in a drawer, and hands the customer an empty bag.

The precise version

  • Running out of flour is an expected failure: handle it by retrying, falling back, or telling the caller.
  • The wrong recipe is a bug: carrying on produces wrong results, so stop that unit of work and fix the code.
  • The note in the drawer is a log-only handler, which Yuan’s study counts as ignoring the error.
  • Where the analogy breaks: in software, the same failure can be either kind, depending on the code. A missing file is expected for a program looking for an optional settings file, and a bug if the installer was supposed to create it. That’s Armstrong’s point about the missing file: the programmer decides.

The four languages then give you different tools for the first two rows.

One failure through three layers

In each language, a small program has a handler that calls an order service, which calls a repository, and the database times out. The service holds a lock it must release whether or not the save works. The handler turns the failure into an HTTP status. (A timeout has one more twist: the insert may have committed before the answer was lost, so a client that retries needs an idempotency key, as in Part 4.)

the database times out: how the failure reaches the handler handler service repository

The lines are the output of the four programs below, which the series’ code verifiers run. The service releases its lock in every language: finally, defer or Drop.

C#: exceptions, all unchecked

try
{
    new OrderService().Place(42);
    Console.WriteLine("handler: 201 Created");
}
catch (TimeoutException e)
{
    Console.WriteLine("handler: 503 Service Unavailable");
    for (Exception? ex = e; ex is not null; ex = ex.InnerException)
    {
        Console.WriteLine($"  {ex.GetType().Name}: {ex.Message}");
    }
}

class OrderRepository
{
    public void Save(int orderId)
    {
        Console.WriteLine($"repository: insert order {orderId} timed out");
        throw new TimeoutException("database did not answer within 2 s");
    }
}

class OrderService
{
    private readonly OrderRepository _repository = new();

    public void Place(int orderId)
    {
        try
        {
            _repository.Save(orderId);
        }
        finally
        {
            Console.WriteLine("service: finally, release the order lock");
        }
    }
}

It prints:

repository: insert order 42 timed out
service: finally, release the order lock
handler: 503 Service Unavailable
  TimeoutException: database did not answer within 2 s

The service has no error-handling code at all, only cleanup. The exception unwinds through it, runs its finally, and reaches the first matching catch. Nothing in Place‘s signature says it can fail. That’s the trade: no noise in the middle layers, and no compiler help in finding which failures a caller should expect.

Three details that decide whether C# error handling works in practice, each checked in our lab on .NET 10:

  • Rethrow with throw;, not throw e;. Microsoft’s reference: “throw; preserves the original stack trace of the exception […] In contrast, throw e; updates the StackTrace property of e.” In our lab, the trace after throw; still named the method that threw; after throw e; it didn’t, and the compiler’s analyzer warned CA2200: Re-throwing caught exception changes stack information.
  • Unobserved task exceptions vanish. A Task that faults and is never awaited raises TaskScheduler.UnobservedTaskException when it’s garbage collected, and since .NET Framework 4.5, “by default, the process does not terminate. Instead, the exception is ignored after the event is raised”. Our lab’s “background write failed” raised the event, and the process kept running, but only because the lab forced garbage collections in a loop. In a real service the event fires whenever the task happens to be collected, or never, so it’s no substitute for awaiting. If nobody observes the task, that failure is gone.
  • async void crashes the process. An exception from an async void method “can’t be caught outside of that method”. Ours ended the process with Unhandled exception. System.InvalidOperationException: async void failed. Use async Task everywhere except event handlers. (In a UI app, the exception goes to the UI framework’s synchronization context first, which may have its own handler; in a console app or server, as here, the process ends.)

.NET’s design guidelines say “❌ DO NOT return error codes” and “✔️ DO report execution failures by throwing exceptions”, where failure means a member “cannot do what it was designed to do”. For failures that are routine, they add the Try-Parse pattern: “DO use the prefix “Try” and Boolean return type for methods implementing this pattern”, as int.TryParse does.

Java: checked and unchecked exceptions

import java.io.Serial;
import java.util.concurrent.TimeoutException;

class OrderUnavailableException extends Exception {
    @Serial
    private static final long serialVersionUID = 1L;

    OrderUnavailableException(String message, Throwable cause) {
        super(message, cause);
    }
}

class OrderRepository {
    void save(int orderId) throws TimeoutException {
        IO.println("repository: insert order " + orderId + " timed out");
        throw new TimeoutException("database did not answer within 2 s");
    }
}

class OrderService {
    private final OrderRepository repository = new OrderRepository();

    void place(int orderId) throws OrderUnavailableException {
        try {
            repository.save(orderId);
        } catch (TimeoutException e) {
            IO.println("service: wrap with context");
            throw new OrderUnavailableException("could not place order " + orderId, e);
        } finally {
            IO.println("service: finally, release the order lock");
        }
    }
}

void main() {
    try {
        new OrderService().place(42);
        IO.println("handler: 201 Created");
    } catch (OrderUnavailableException e) {
        IO.println("handler: 503 Service Unavailable");
        for (Throwable t = e; t != null; t = t.getCause()) {
            IO.println("  " + t.getClass().getSimpleName() + ": " + t.getMessage());
        }
    }
}

It prints:

repository: insert order 42 timed out
service: wrap with context
service: finally, release the order lock
handler: 503 Service Unavailable
  OrderUnavailableException: could not place order 42
  TimeoutException: database did not answer within 2 s

TimeoutException is a checked exception, so the compiler makes every method in the path either catch it or declare it with throws. The Java Language Specification defines the split by class: “The unchecked exception classes are the run-time exception classes and the error classes”, meaning RuntimeException, Error and their subclasses; everything else under Throwable is checked. The purpose, in the spec’s words: “This compile-time checking for the presence of exception handlers is designed to reduce the number of exceptions which are not properly handled.”

The service here doesn’t pass TimeoutException on. It wraps it in an exception that belongs to its own layer, keeping the original as the cause. The Throwable documentation explains why: letting the lower layer’s exception out “would tie the API of the upper layer to the details of its implementation”. If OrderRepository later moves from a database to an HTTP service, OrderService‘s contract doesn’t change.

What the compiler says when you forget, from our lab on JDK 25:

  • Remove throws IOException from a method that calls one that throws it: error: unreported exception IOException; must be caught or declared to be thrown.
  • Call that method through a method reference in a stream, map(Checked::read): error: incompatible thrown types IOException in functional expression. The java.util.function interfaces declare no checked exceptions, so streams and lambdas force you to wrap them. This is the most common daily friction with checked exceptions.

Why C# left them out. Anders Hejlsberg, C#’s lead designer, in a 2003 interview with Bill Venners and Bruce Eckel: “I see two big issues with checked exceptions: scalability and versionability.” Adding an exception to a throws clause “is a breaking change”, and exceptions accumulate as subsystems combine: “It just balloons out of control.” What developers then do: “They either say, “throws Exception,” everywhere; or […] they say, “try, da da da da da, catch curly curly.”” He didn’t call the idea bad, though: “there’s nothing wrong with the idea”, he said, “It’s just that particular implementations can be problematic.” And Java’s own specification says “it is typical to define most new exception classes as checked exception classes”. The Java Tutorial’s rule of thumb: “If a client can reasonably be expected to recover from an exception, make it a checked exception. If a client cannot do anything to recover from the exception, make it an unchecked exception.”

Cleanup that fails. try-with-resources closes resources in reverse order, and if closing throws after the body already threw, the close exception “is suppressed”: attached to the first exception, not thrown instead of it.

class Connection implements AutoCloseable {
    void query() {
        throw new IllegalStateException("query failed: connection reset");
    }

    @Override
    public void close() {
        throw new IllegalStateException("close failed: connection already broken");
    }
}

void main() {
    try (Connection connection = new Connection()) {
        connection.query();
    } catch (IllegalStateException e) {
        IO.println("caught: " + e.getMessage());
        for (Throwable suppressed : e.getSuppressed()) {
            IO.println("suppressed: " + suppressed.getMessage());
        }
    }
}

It prints:

caught: query failed: connection reset
suppressed: close failed: connection already broken

A hand-written finally that throws does the opposite: its exception replaces the original, and the real cause is lost.

One Java exception needs special care. When a blocking call throws InterruptedException, the thread’s interrupt status has been cleared. Brian Goetz: “The worst thing you can do with InterruptedException is swallow it”. If you can’t rethrow it, call Thread.currentThread().interrupt() before returning, so code higher up can still see that the thread was asked to stop.

Go: errors are values

package main

import (
	"errors"
	"fmt"
)

var ErrTimeout = errors.New("database did not answer within 2 s")

type OrderRepository struct{}

func (OrderRepository) Save(orderID int) error {
	fmt.Printf("repository: insert order %d timed out\n", orderID)
	return fmt.Errorf("insert order %d: %w", orderID, ErrTimeout)
}

type OrderService struct{ repository OrderRepository }

func (s OrderService) Place(orderID int) error {
	defer fmt.Println("service: deferred, release the order lock")
	if err := s.repository.Save(orderID); err != nil {
		fmt.Println("service: wrap with context")
		return fmt.Errorf("place order %d: %w", orderID, err)
	}
	return nil
}

func main() {
	err := OrderService{}.Place(42)
	switch {
	case err == nil:
		fmt.Println("handler: 201 Created")
	case errors.Is(err, ErrTimeout):
		fmt.Println("handler: 503 Service Unavailable")
		fmt.Println("  " + err.Error())
	default:
		fmt.Println("handler: 500 Internal Server Error")
	}
}

It prints:

repository: insert order 42 timed out
service: wrap with context
service: deferred, release the order lock
handler: 503 Service Unavailable
  place order 42: insert order 42: database did not answer within 2 s

In Go, a failure is an ordinary return value of the built-in error interface type. Nothing unwinds: every layer receives the error, decides, and returns. The Go FAQ explains the choice: “coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.” Rob Pike’s 2015 post gave the idea its slogan: “Errors are values. Values can be programmed, and since errors are values, errors can be programmed.”

Since Go 1.13, fmt.Errorf with %w wraps an error, so errors.Is and errors.As can find it inside, “consider[ing] all the errors in a chain”. Each layer adds context to the message, and the handler still finds the sentinel ErrTimeout. Our lab shows the difference %w makes:

package main

import (
	"errors"
	"fmt"
)

var ErrTimeout = errors.New("database timeout")

type QueryError struct{ Table string }

func (e *QueryError) Error() string { return "query on " + e.Table + " failed" }

func main() {
	withW := fmt.Errorf("place order: %w", ErrTimeout)
	withV := fmt.Errorf("place order: %v", ErrTimeout)
	fmt.Println("same text:", withW.Error() == withV.Error())
	fmt.Println("wrapped with w, matches:", errors.Is(withW, ErrTimeout))
	fmt.Println("formatted with v, matches:", errors.Is(withV, ErrTimeout))

	joined := errors.Join(fmt.Errorf("insert: %w", &QueryError{Table: "orders"}), ErrTimeout)
	qe, ok := errors.AsType[*QueryError](joined)
	fmt.Println("found QueryError:", ok, qe.Table, "and timeout:", errors.Is(joined, ErrTimeout))
}

It prints:

same text: true
wrapped with w, matches: true
formatted with v, matches: false
found QueryError: true orders and timeout: true

The text is identical; only what code can inspect changes. Go 1.20 added errors.Join and multiple %w, so errors form a tree, and Go 1.26 added the generic errors.AsType, which the package documentation now prefers over errors.As.

Wrapping has a design cost that the Go team spells out: “wrapping an error makes that error part of your API. If you don’t want to commit to supporting that error as part of your API in the future, you shouldn’t wrap the error.” If your service’s callers can match your database driver’s errors, you can’t change drivers without breaking them. Wrap errors you promise; for implementation details, format with %v, or return your own error.

What Go doesn’t check, from our lab with Go 1.26:

  • Ignoring an error compiles silently. os.Remove("does-not-exist.txt") as a bare statement, and f, _ := os.Open(...), both built, and go vet reported nothing. The compiler doesn’t catch them. The errcheck linter reports unchecked calls like the first, and assignments to _ like the second only with its -blank flag. The Code Review Comments page: “Do not discard errors using _ variables.”
  • A panic in another goroutine ends the program. Go does have panic and recover, which unwind like exceptions, but recover only works in a deferred function on the same goroutine. Ours had recover in main, a goroutine panicked with “worker failed”, and the process exited with status 2. A recover in main can’t save you. Go’s net/http server recovers for its handlers: if a handler panics, the server “recovers the panic, logs a stack trace to the server error log” and closes that connection. A goroutine the handler starts itself isn’t covered. Recovering doesn’t make a bug safe, though: shared state may be half-updated. Recover at a boundary, such as a request or a worker, to report the failure and end that unit of work, not to carry on.

Effective Go limits panic to “truly exceptional” cases: “real library functions should avoid panic”. And in June 2025, after three proposals of its own and “literally hundreds” from the community, the Go team announced that “For the foreseeable future, the Go team will stop pursuing syntactic language changes for error handling.” The if err != nil style is staying.

Rust: Result and ?

use std::error::Error;
use std::fmt;

#[derive(Debug)]
enum RepositoryError {
    Timeout,
}

impl fmt::Display for RepositoryError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            RepositoryError::Timeout => write!(f, "database did not answer within 2 s"),
        }
    }
}

impl Error for RepositoryError {}

#[derive(Debug)]
struct PlaceOrderError {
    order_id: u32,
    source: RepositoryError,
}

impl fmt::Display for PlaceOrderError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "could not place order {}", self.order_id)
    }
}

impl Error for PlaceOrderError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.source)
    }
}

fn save(order_id: u32) -> Result<(), RepositoryError> {
    println!("repository: insert order {order_id} timed out");
    Err(RepositoryError::Timeout)
}

struct OrderLock;

impl Drop for OrderLock {
    fn drop(&mut self) {
        println!("service: drop, release the order lock");
    }
}

fn place(order_id: u32) -> Result<(), PlaceOrderError> {
    let _lock = OrderLock;
    save(order_id).map_err(|source| {
        println!("service: wrap with context");
        PlaceOrderError { order_id, source }
    })?;
    Ok(())
}

fn main() {
    match place(42) {
        Ok(()) => println!("handler: 201 Created"),
        Err(e) => {
            let status = match e.source {
                RepositoryError::Timeout => "503 Service Unavailable",
            };
            println!("handler: {status}");
            let mut current: Option<&dyn Error> = Some(&e);
            while let Some(err) = current {
                println!("  {err}");
                current = err.source();
            }
        }
    }
}

It prints:

repository: insert order 42 timed out
service: wrap with context
service: drop, release the order lock
handler: 503 Service Unavailable
  could not place order 42
  database did not answer within 2 s

Rust makes failure part of the return type, like Go, and part of the type system, unlike Go: Result<T, E> is an enum, so you can’t reach the T without handling the E. The Book: “Rust doesn’t have exceptions. Instead, it has the type Result<T, E> for recoverable errors and the panic! macro that stops execution when the program encounters an unrecoverable error.”

The ? operator removes most of Go’s repetition. On an Err, it returns early, and the Reference defines exactly what it returns: “Result::Err(e) returns Result::Err(From::from(e)).” So ? converts the error into the function’s error type, if a From conversion exists. Our lab left it out:

use std::num::ParseIntError;

#[derive(Debug)]
enum ConfigError {
    BadPort(ParseIntError),
}

fn port(raw: &str) -> Result<u16, ConfigError> {
    let n = raw.parse::<u16>()?;
    Ok(n)
}

fn main() {
    println!("{:?}", port("8080"));
}
error[E0277]: `?` couldn't convert the error to `ConfigError`

Adding impl From<ParseIntError> for ConfigError, which wraps it in BadPort, makes it compile; that From impl is the Rust version of wrapping with a cause. The Error trait’s source() method is the chain, which the handler above walks.

What Rust checks, and what it only warns about:

  • Ignoring a Result is a warning, not an error. Result is #[must_use]. Our fs::remove_file("does-not-exist.txt"); compiled with warning: unusedResultthat must be used, and ran. Teams that want it enforced deny the lint, or deny all warnings in CI. The compiler’s own suggestion, let _ = ..., silences it on purpose, so review those too.
  • unwrap() turns an error into a panic. A panic unwinds the thread by default. In our lab, a worker thread called unwrap() on Err("feature file has 201 entries, limit 200"): the thread panicked, and join() returned Err, so main kept running. Running isn’t the same as safe: a thread that panics while holding a Mutex poisons it, and the standard library’s docs explain that the data “is likely tainted (some invariant is not being upheld)”. In a program built with panic = "abort", the whole process would stop instead.
  • Don’t use catch_unwind as try/catch. Its documentation: “It is not recommended to use this function for a general try/catch mechanism”, and it “only catches unwinding panics, not those that abort the process.”

The Book’s guidance on choosing: “returning Result is a good default choice when you’re defining a function that might fail”, and panic “when it’s possible that your code could end up in a bad state”, meaning “some assumption, guarantee, contract, or invariant has been broken”. In practice many Rust libraries define their error enums with the community crate thiserror, and applications collect errors with anyhow. thiserror’s README frames it as whether the caller needs “exactly the information that you choose”, which is “most often” library-like code.

Measured: what a failure costs

“Exceptions are slow” is usually said without a number. We made 20,000 calls that all fail, with 1 or 10 function frames between the function that fails and the loop that handles the failure, and timed the failure path in C# (.NET 10.0.302, the JIT, a Release build), Go 1.26 and Rust 1.95. Each program ran as 3 processes pinned to one performance core of the CPU, 30 rounds each after 10 warm-up rounds were dropped. The numbers are medians per failed call; in brackets, the ratio to returning the failure as a plain value at the same depth, paired by round. We didn’t measure Java, for the same reason as in Part 8: fair JVM numbers need the JMH harness.

one failed call, median time: blue returns a value, red unwinds the stack C#: return false C#: exception Go: return error Go: %w in every frame Go: panic, recover Rust: Err with ? Rust: panic, catch_unwind 1 ns 10 ns 100 ns 1 µs 10 µs

Measured by checks/part10_cost/run.py on one machine (12th Gen Intel(R) Core(TM) i5-1235U), pinned to one core, 3 processes × 30 kept rounds. The frames do no work, so real unwinding through cleanup code costs more.

Language How the failure is reported Depth 1 Depth 10
C# returned false 4.2 ns 18.6 ns
C# exception, caught at the top 3.0 µs (709×) 7.6 µs (422×)
Go returned sentinel error 3.2 ns 27.0 ns
Go fmt.Errorf with %w in every frame 212.4 ns (64×) 2.4 µs (91×)
Go panic, recover at the top 247.2 ns (78×) 465.2 ns (17×)
Rust Err with ? 2.1 ns 13.6 ns
Rust panic!, catch_unwind at the top 2.6 µs (1292×) 4.5 µs (331×)

What the run shows:

  • Returning a failure costs nanoseconds. A C# false, a Go sentinel error and a Rust Err cost 4.2 ns, 3.2 ns and 2.1 ns at depth 1, and 18.6 ns, 27.0 ns and 13.6 ns through 10 frames.
  • Unwinding costs microseconds. A C# exception cost 3.0 µs at depth 1 and 7.6 µs at depth 10. A Rust panic caught with catch_unwind cost 2.6 µs and 4.5 µs. Go’s panic and recover cost 247.2 ns and 465.2 ns. These three aren’t like for like: a C# exception object captures a stack trace, our Rust test silenced the panic hook, which normally prints a message, and the Go panic carried a prebuilt error value and captured no trace. Compare mechanisms within a language more than across them.
  • Context isn’t free either. Wrapping with fmt.Errorf and %w in every one of 10 frames cost 2.4 µs, more than a Go panic through the same frames, because every wrap formats a string and allocates. Add context at layer boundaries, not in every function.
  • What that means for design. A few microseconds don’t matter for a request that fails once: the database round trip that failed took milliseconds. They matter when failure is the normal path, such as a parser rejecting millions of malformed lines, which is exactly where .NET’s guidelines recommend the Try-Parse pattern. The C# cost also grew with depth, by about half a microsecond per frame here, and a web request’s stack is often much deeper than 10 frames.
  • One machine, trivial frames. A 12th Gen Intel(R) Core(TM) i5-1235U, one core. Our frames do no work and hold nothing to clean up; real frames with finally blocks, defers or destructors add to the unwinding cost. The large ratios mostly show how cheap a returned value is.

Where to handle an error

Most errors should pass through most layers untouched, apart from context. Hejlsberg’s rule of thumb from the same interview: “In a well-written application there’s a ratio of ten to one, in my opinion, of try finally to try catch.” Code in the middle cleans up; code at the edges decides.

Handle an error where you can do something about it. That’s one of four things:

  1. Recover: retry a transient failure, with backoff and only for idempotent operations (Part 4), or fall back to a cached or default value.
  2. Translate: turn a lower layer’s error into one that belongs to your layer, keeping the original as the cause or source.
  3. Report: at a boundary, turn the error into a response, a message status, or an exit code.
  4. Stop: for a bug or a broken invariant, let the process or request fail and let a supervisor, orchestrator or load balancer take over.

Logging isn’t on the list. A handler that only logs is, in Yuan’s study, “ignoring the error”. Log once, at the boundary that handles it, with the full chain; a layer that logs and rethrows produces the same failure five times in the logs.

At an HTTP boundary, map error kinds to status codes, and return details the client can act on without leaking internals. RFC 9457, Problem Details for HTTP APIs (2023, replacing RFC 7807), says the detail member “ought to focus on helping the client correct the problem, rather than giving debugging information”, and that problem details “are not a debugging tool for the underlying implementation”. A stack trace belongs in your logs, not in the response. gRPC’s status codes encode retry advice: “Use UNAVAILABLE if the client can retry just the failing call”, ABORTED to retry at a higher level, and FAILED_PRECONDITION when the client “should not retry until the system state has been explicitly fixed”.

Cancellation isn’t failure. When a client disconnects or gives up, .NET code sees OperationCanceledException; Microsoft’s advice is that “It’s better to catch OperationCanceledException instead of TaskCanceledException”. Go code sees context.Canceled, “the error returned by Context.Err when the context is canceled for some reason other than its deadline passing”, or context.DeadlineExceeded when a deadline passed. Check for these at the boundary before anything else, so a user who closed the tab doesn’t show up as a 500 in your error rate.

In ASP.NET Core, that boundary is the exception handler middleware. An IExceptionHandler (ASP.NET Core 8+) handles known exceptions “in a central location”, but only if the app also calls UseExceptionHandler: “If UseExceptionHandler isn’t called, the registered IExceptionHandler implementations are never called.” A change in .NET 10 to know about: “the default behavior is to suppress emission of diagnostics such as logs and metrics for handled exceptions”, so an exception your handler marks as handled (when TryHandleAsync returns true) no longer shows up in logs unless you log it, or set SuppressDiagnosticsCallback to change the default.

In a Go server, the boundary is a middleware that maps errors.Is or errors.As results to status codes, with net/http already recovering panics per request. In a Rust service, it’s a conversion from your error enum into a response, written as a match on its cases, so adding an error case makes the compiler point at the mapping to update.

Crash, or continue wrongly?

Two outages in 2025 show the choice from the other side.

On 18 November 2025, Cloudflare’s network failed to deliver core traffic for hours. A database permissions change produced a feature file twice its usual size. The new Rust proxy preallocated memory for at most 200 features, and when the file exceeded that, the code “panicked: called Result::unwrap() on an Err value”. Cloudflare called it their “worst outage since 2019”. Their older proxy didn’t crash on the same file, but “bot scores were not generated correctly, resulting in all traffic receiving a bot score of zero”. One engine failed loudly; the other kept running and produced wrong answers.

On 12 June 2025, Google Cloud’s Service Control crashed globally. A new code path for quota policy checks “did not have appropriate error handling nor was it feature flag protected”, and policy data with “unintended blank fields” hit a null pointer, putting “the binaries […] into a crash loop”. Google’s fix includes making the functionality “isolated” so it “fails open”.

Neither lesson is “always crash” or “never crash”. They’re these:

  • Treat your own configuration and data like user input. Cloudflare’s remediation starts with “Hardening ingestion of Cloudflare-generated configuration files in the same way we would for user generated input”. Parse it at load time (Part 9), keep the last good version, and refuse a bad one before it reaches the hot path.
  • Decide fail-open or fail-closed per feature, in advance. A bot score or a quota check might fail open; a payment authorization or an access check must fail closed. Write the choice down, and test the failure path.
  • Crashing is only safe when the crash is contained: one request, one worker, one cell, restarted by something else. A crash that every replica hits on the same bad input, at the same moment, is a global outage.

Across languages

C# Java Go Rust
Expected failure exception; Try pattern for routine cases checked exception error return value Result<T, E>
Bug or broken invariant exception, usually not caught; Environment.FailFast unchecked exception (RuntimeException), Error panic panic!
Compiler forces handling no checked exceptions: yes, including in lambdas no; ignored errors compile to get the value you must handle the Err (match, if let, ?) or panic on it (unwrap, expect); ignoring a Result warns
Adding context wrap as InnerException wrap as cause fmt.Errorf("...: %w", err) error enum with source(), From, map_err
Inspecting the cause catch (T), when filters catch (T), getCause() errors.Is, errors.As, errors.AsType match on the enum, downcast_ref
Cleanup finally, using finally, try-with-resources defer Drop
An exception or panic in another thread plain thread: process ends; unobserved Task: ignored plain thread: that thread dies, the process continues; executor task: kept in the Future until get() the whole program ends the thread ends; join returns Err (unless panic = "abort")

Trade-offs

  • Exceptions keep middle layers clean and hide the failure paths. Reading Place(42) in C#, you can’t tell what can go wrong. Errors as values show every failure path, at the cost of repetition and, in Go, errors that can be silently ignored.
  • Checked exceptions document failures and couple layers. Every new checked exception is a signature change up the stack, and lambdas passed to the standard java.util.function interfaces, as streams use, can’t throw them. Java’s own guidance still favours them for recoverable failures; many codebases wrap them in unchecked exceptions at module boundaries.
  • Rich error types help callers and cost you compatibility. Every error case or wrapped error you expose is part of your API. Expose what callers can act on, and keep the rest opaque.
  • Crashing on bugs needs a supervisor. Letting a process die is safe with an orchestrator, health checks and more than one replica. In a monolith holding thousands of user sessions, you contain bugs per request instead, and still don’t swallow them.
  • Failure paths are rarely tested. Yuan’s study found that in 58% of the catastrophic failures “the underlying faults could easily have been detected through simple testing of error handling code”. Whatever model you pick, write tests that make each dependency fail.

Common mistakes

  • Catch, log, carry on. The customer sees success and the data is lost. Handle, translate, report, or let it propagate.
  • Catching the base type. catch (Exception) or catch (Throwable) in the middle of a call stack catches bugs too. In one HDFS failure Yuan describes, a handler meant for one version error caught Throwable and shut down every datanode on an unrelated remote exception.
  • throw e; in C#. It resets the stack trace. Use throw;, or ExceptionDispatchInfo to rethrow later.
  • async void and fire-and-forget tasks in C#. The first crashes the process; the second loses the exception.
  • Logging at every layer. One failure, five log entries, no single place with the whole story.
  • Wrapping implementation details into your API. A caller matching your database driver’s error type in Go or Java ties them to your driver.
  • Error strings that end in punctuation or start with a capital in Go and Rust. Both communities’ conventions keep messages lowercase without trailing punctuation, because they get joined into longer messages.
  • unwrap() on data that “can’t be wrong”. Internal configuration and data from your own systems can be wrong. Parse it and return an error. Keep expect, with a message saying why it can’t fail, for real invariants.
  • Returning internal details in API errors. Stack traces and SQL messages help attackers and don’t help clients. Log them; return a problem type and an ID.

Interview questions

Try to answer each one before opening the model answer.

1. Compare exceptions, error return values and Result types.

Show a strong answer
  • Exceptions (C#, Java): failures unwind the stack to the nearest matching handler. Middle layers need only cleanup code. The failure paths are invisible in the code, and in C# in the signatures.
  • Checked exceptions (Java): the compiler requires every method to catch or declare them. They document failures, but they change signatures up the stack and don’t work well with lambdas.
  • Error values (Go): failures are ordinary return values. Every path is explicit, context is added with %w, and nothing forces you to check.
  • Result (Rust): an enum you must handle to get the value; ? propagates with conversion through From. It’s explicit like Go and checked like Java. Closures have their own wrinkle: ? inside a closure returns from the closure, so iterators collect into a Result instead.
  • All four also have a mechanism for bugs: unchecked exceptions, panic, panic!.

Likely follow-up: “Which would you choose for a new service?” Usually the idiom of the language, applied consistently. The design decisions are the same in all four: which failures are expected, where each is handled, and what the boundary returns.

2. Why doesn’t C# have checked exceptions?

Show a strong answer
  • Hejlsberg’s reasons (2003): “scalability and versionability”. Adding an exception to a throws clause breaks callers, and exception lists grow as subsystems are combined.
  • The observed result in Java: throws Exception everywhere, or empty catch blocks, which he describes as common.
  • Not a rejection of the idea: he said “there’s nothing wrong with the idea”; the issue is the implementation.
  • The counterpoint: Java’s specification recommends checked exceptions for most new exception classes, and Midori used a slimmed-down checked model successfully because bugs were handled separately by abandonment.

Likely follow-up: “How do you get some of the benefit in C#?” Document exceptions in XML docs, use the Try pattern or result types for expected failures, and use analyzers.

3. When should code panic or let an exception crash the process?

Show a strong answer
  • For bugs and broken invariants, not for expected failures. The Rust Book: panic when “some assumption, guarantee, contract, or invariant has been broken”. Duffy: “Proceeding in the face of a bug is dangerous”.
  • Only when the crash is contained: a request, a worker or a process that something else restarts. Erlang’s “let it crash” comes with “Let some other process do the error recovery”.
  • Not on data that can legitimately be wrong: input, configuration, responses from other services. Cloudflare’s 2025 outage was an unwrap() on a configuration file that broke a size limit.
  • Beware correlated crashes: if every replica crashes on the same input, restarts don’t help.

Likely follow-up: “What’s fail-open versus fail-closed?” On an internal failure, fail open means allowing the operation (such as skipping a bot check), and fail closed means refusing it (such as denying access). Decide per feature by the cost of each wrong outcome.

4. What does errors.Is do, and when should you wrap with %w versus %v?

Show a strong answer
  • errors.Is(err, target) walks the tree of wrapped errors and reports whether any matches target. errors.As and errors.AsType find one of a given type.
  • %w wraps: the message includes the inner error, and errors.Is can find it. %v formats only: the same text, but the inner error is no longer inspectable. Our lab showed identical strings and different errors.Is results.
  • Wrap errors that are part of your contract. The Go blog: “wrapping an error makes that error part of your API”. Don’t wrap errors that expose implementation details, such as a specific database driver.
  • Multiple errors: errors.Join and multiple %w since Go 1.20.

Likely follow-up: “Sentinel errors or error types?” A sentinel (var ErrNotFound = errors.New(...)) when callers only need to know which failure happened; a type when they need data from it, such as which field was invalid.

5. How do you design error handling for a REST API?

Show a strong answer
  • One boundary: middleware or an exception handler maps error kinds to status codes and bodies, and logs once, with the full chain and a correlation ID.
  • Categories, using the meanings RFC 9110 gives each code: invalid request (400, or 422 when the syntax is fine but the content can’t be processed), not authenticated (401), not allowed (403), not found (404), conflict with the resource’s current state, including business-rule conflicts (409), a failed conditional header such as If-Match (412), rate limited (429, from RFC 6585), a dependency unavailable (503), a bug (500). 504 is for a server “acting as a gateway or proxy”, not for your own database timing out.
  • Body: RFC 9457 problem details, application/problem+json, with a stable type URI and a detail that helps the client, never a stack trace.
  • Retry advice: a Retry-After header on 503, which RFC 9110 says “indicates how long the service is expected to be unavailable”, and on 429, which RFC 6585 says “MAY include a Retry-After header”. Only retry idempotent operations, or those with idempotency keys (Part 4): after a timeout, the first attempt may have succeeded.
  • In ASP.NET Core: IExceptionHandler plus UseExceptionHandler, and in .NET 10 log inside the handler, because handled exceptions are no longer logged by default.

Likely follow-up: “What about errors from downstream services?” Translate them. Your clients shouldn’t see a downstream service’s error format or depend on it.

6. What’s wrong with catching an exception, logging it and continuing?

Show a strong answer
  • The caller believes the operation succeeded. Data is lost, or later steps run on a false assumption.
  • Evidence: in Yuan et al.’s OSDI 2014 study, 25% of catastrophic failures came from ignored errors, and a log-only handler counted as ignoring. In all but one case, developers had checked for the error; the handling was what went wrong.
  • Logs aren’t alerts: a warning line is rarely seen until someone investigates the damage.
  • Instead: handle it (retry, fall back), translate and rethrow or return it, or let it propagate to a boundary that reports it. If continuing really is correct, make that decision explicit and count it in a metric.

Likely follow-up: “How would you find these in an existing codebase?” Search for empty or log-only catch blocks and _ = discarded errors, run linters (errcheck in Go, unused_must_use denied in Rust), and write tests that make each dependency fail.

7. How are exceptions from background work handled in C#, Go and Rust?

Show a strong answer
  • C# tasks: an awaited Task rethrows on await. A task nobody awaits loses its exception: UnobservedTaskException fires on garbage collection and the process continues. async void exceptions crash the process.
  • Go: a panic in any goroutine ends the whole program unless that goroutine recovers it. Errors from goroutines must be sent back explicitly, for example through a channel, or with golang.org/x/sync/errgroup, which adds “error propagation, and Context cancellation for groups of goroutines”.
  • Rust: a panicking thread ends that thread, and JoinHandle::join returns Err. Errors are returned as the thread’s Result. With panic = "abort", any panic ends the process.
  • Java: an uncaught exception ends that thread, prints its stack trace, and the process continues. A task given to an ExecutorService with submit() keeps its exception in the Future, and nothing is reported until get() throws ExecutionException; a task given to execute() goes to the thread’s uncaught exception handler instead.
  • C# plain threads: an unhandled exception on a Thread ends the process.

Likely follow-up: “How do you avoid losing them?” Always await, join or get() what you start; give background work an owner that waits for it and collects its errors (an errgroup in Go; Task.WhenAll in C#, whose returned task holds every exception, although await rethrows only one of them: in our lab, two tasks failed, await threw one exception, and the task’s Exception held both); and report failures to monitoring.

8. Are exceptions too slow to use for errors?

Show a strong answer
  • Throwing is the expensive part. Our measurements in the post give the failure-path costs in C#, Go and Rust.
  • So the question is how often they’re thrown. For failures that happen on a normal request, like parsing user input, use a non-throwing API (TryParse, a result type). For rare failures, like a lost database connection, the cost doesn’t matter.
  • .NET 9 made exception handling faster: “2-4 times faster, per some exception handling micro-benchmarks”. The old guideline that “Throw rates above 100 per second are likely to noticeably impact the performance” dates from 2008.
  • Go and Rust error values aren’t free either: adding context allocates, and wrapping in every frame costs more than returning a sentinel.

Likely follow-up: “When would you benchmark it?” When a profile shows exception handling in a hot path, or when failures are part of normal traffic, such as validation of untrusted input at high request rates.

Sources

What to remember

  • First decide what kind of failure it is: an expected failure to handle, a bug to stop on, or a fatal condition. The mechanism comes second.
  • C# exceptions keep the middle clean and hide failure paths; Java’s checked exceptions document them and ripple through signatures; Go’s error values are explicit and unchecked; Rust’s Result is explicit and checked.
  • Handle an error where you can recover, translate or report it. A handler that only logs has ignored it.
  • Add context as the error travels (a cause, %w, source()), but only expose errors you’re willing to support as API.
  • Log once, at the boundary. Return problem details, not stack traces.
  • In C#, rethrow with throw;, never async void, and await every task. In Go, remember a panic in any goroutine ends the program, and recover only at boundaries that report and stop. In Rust, don’t unwrap() data that can be wrong.
  • Crash on bugs only where the crash is contained and restarted, and treat your own configuration like user input.

An error that nobody sees is a bug that nobody fixes.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.