I'm not sure his example for "exceptions are too complicated" is very
good. When deliver mail, the operation isn't "first try the primary
server, then try the backup server". It's "try the servers in
ascending order of priority, randomly selecting if the priorities are
the same". From that, the code becomes much simpler:

def servers := ( sort possible servers according to rules );
def sent_to;

send_attempt: for server in servers {
try {
server->send_mail_to( message );
sent_to := $server;
last send_attempt;
}
catch Temporary Failure {
logger->note_temporary_failure( ... )
}
}

if(!sent_to)
throw Permanent Failure ("EPIC FAIL");

The key is to use exceptions for what they're good for: unwinding the
stack under certain conditions. We want the stack to unwind if there
is some reason to abort mail sending completely ("network cable not
plugged in"), but we only want to unwind to the point of trying the
next server if the server is simply unavailable.

Using error codes alone would be complicated ("if result_code ==
TRY_AGAIN_LATER then { try the other server } otherwise { return
TOTALLY_FAILED_DUDE }"), and using exceptions without the "sent_to"
state would also be quite complicated (I'm not really sure how to even
do that).

The key is to use the features for what they're good at, rather than
to treat them like an ideology.

From an ideology perspective, your language's designer already decided
what he wants you to do. If you can accidentally ignore an error,
you're using the wrong one.

That means in Java, you tend to favor exceptions, because you get some
compile-time type checking to make sure you're handling them. And if
you don't handle them, your program exits at runtime, which is a good
thing. Return codes, on the other hand, are easy to ignore, and your
program produces undefined results if you forget to check them (and no
compiler is going to tell you where that is: you'd better have good
tests, tolerant users, and no deadline).

In Haskell, on the other hand, the ideology is reversed. If you throw
exceptions in IO, they basically work like exceptions in untyped
languages -- your program exits unless you were lucky enough to have a
runtime handler. (If you throw them outside of IO, like with "error",
you're double fucked. You can't even catch them outside of IO, which
basically means Never Do That.)

But the good news is, there are type-safe return codes. You define a
type like:

data Either a b = Right a | Left b

and then you make your functions return Either the Right answer or an
error. If you have a function that returns an error code, you can't
do anything with the result until you intentionally handle the error.
If you forget, your program simply does not compile -- there's no way
to ignore errors except by explicitly writing code to ignore the
error.

And, of course, this is generally hidden from the programmer with
monads! You don't even have to think about handling the return codes:
the language does it for you.

(C is a weird case where both exceptions and error codes work poorly.
For that reason, I make sure to write C in very tiny pieces that can
be composed with a safer high-level language. In that case, there are
only a few places where errors can occur, and they tend to be simple
like "tried to allocate memory, it didn't work, so i deallocated
everything else" or "failed to send to the socket: EAGAIN". The
complex logic like "failed to send to primary MX" should be handled
higher up in your stack.)