6 ms·
Java exception handling is very successful, and does exactly what I want as a system designer. The article objects to the fact that applications handle many ex
by jcrites 10y ago
Java exception handling is very successful, and does exactly what I want as a system designer.
The article objects to the fact that applications handle many exceptions by logging them and giving up. What's wrong with that? That's perfect. For the class of exceptions that I can just log and give up, that's often what I want to do. If I had an open TCP socket to a remote host, then I can't "recover" from an exception indicating that the connection was broken. Instead, I just want to tear down everything related to that connection and carry on.
Exceptions can rarely be recovered from at the level of abstraction where the exception occurred. For example, I can't repair a socket that's been broken. I cannot retry the request at that level - it might not be idempotent; the socket processing code can't know. The connection pool doesn't know. The HTTP client might or might not know. However, I can recover from the exception at a higher level -- at the level of the code that understands what the task is, it might retry the top level processing job, which might invoke a call on a service client, which will invoke a call on an HTTP client, which will invoke a call on an HTTP connection pool to open a new TCP socket as needed. This is exactly the pattern that we see commonly in Java applications: a higher level module calls a lower level one, and then variously logs exceptions and tries again (possibly with exponential backoff over multiple attempts), or logs them and give up, or does something else.
You don't really "repair" or "recover" from exceptions. You mitigate them by handling them and carrying on with the job. To achieve this, you unwind up to the level where it makes sense to retry or abort a task. When you retry, you retry from the right level of abstraction needed to start over.
The extremely valuable property that exceptions have, that they give you, is confidence about what specifically went wrong. They constrain it. If I try to interact with a socket and I get an IO exception, then I know the error is constrained specifically to that socket. The rest of the program is working fine, and I can either try to recover from that error (sometimes possible), or close the connection and open a new one, or give up processing the task.
Exceptions are also valuable because you don't have to add error-handling logic at each layer in an application. Many layers can be ignorant or agnostic of the exceptions that will propagate through them, and that's a good thing because it reduces coupling. These layers might be inserted between the high-level logic that understands task processing, and that will make the judgment call about retry/backoff/give up, and the specific code that can fail and throw an exception. I don't want all layers of code to have to care about exceptions that might pass through them; they can't and shouldn't do anything about it.
It is valuable to be able to build abstractions and build code that exceptions pass through, without handling them. You might add logic to handle them later, or you might do so at a higher level, or a lower level.
- nercury 10y agoRetrying on any exception might be wrong thing to do. For example, the I/O error many levels bellow might happen for wildly different reasons, and handling them all the same way might be wrong. If not, then the correct behavior of the project depends on exceptions originating from different modules being handled the same, and that increases coupling.
- jcrites 10y agoYou make a good point and I agree. However, code also doesn't exist in a vacuum; when it's first written it's not perfect. Systems are built iteratively as the problem becomes gradually more understood. The wonderful thing about exceptions is they fit well into this evolution process. I can wrap the top level application processing step in a try/catch block, and I can be confident that we have a plausible starting point. In the very least, I can be confident that I will detect all reasonable failures, and I can be confident they won't spill over into anything else the application is doing. It's very difficult to crash a Java application that's designed to be robust. If any of my low-level exceptions bubble up into the top-level catch block, when we're expecting to handle all exceptions at lower levels, then I have the ability to do something reasonable in my catch block: abort the task, emit metrics, and log the full stack trace as part of my "unexpected exception" routine. That's really useful: it's one way to discover exceptions that the system ought to handle but isn't handling yet. You might write one of these catch blocks with the expectation that it "should never happen", but if it does we'll detect it and supply a reasonable default behavior. For example, if my application is a microservice, then I will wrap each microservice operation in a try/catch block, and in the catch block I will include logic to convert the exception into an appropriate failure return code from the operation. Or even better, my service framework will handle this for me -- that's an example of the decoupling that exceptions provide as an abstraction. If any microservice operation throws an undeclared exception, then the framework can surface it as the equivalent of HTTP 503. We might set the expectation that all exceptions should be declared exceptions, but if we ever miss one and have an undeclared exception, then the framework can return an appropriate error response. The right thing happens by default: we don't need detect-and-propagate logic in each call frame. Some of those layers could also catch and retry the exception, or catch it and refine the exception type, or just catch and log it and emit task-specific log entries or metrics. For example, perhaps I have a try/catch block around some logic that calls S3. A catch block around the S3 calls can emit metrics describing the failure rate of calls to S3, which are useful to alarm on, and to examine distinct from the overall failure rate of my microservice operation which might call other services too. This is an example where a simple catch-observe-propagate-or-rethrow adds meaningful value. At the same time, those failures could happen for many different reasons. Perhaps my credentials have expired, and so I'll see a 100% failure rate with that step: emergency. Perhaps I'm using a presigned URL, and it's past the expiration time. Perhaps there is a random transient failure. Perhaps the file I'm uploading is too big, or perhaps its checksum doesn't match, and so on. We can start with a basic naive catch block around S3 and refine it over time to handle these cases if we observe that it's relevant to do so. Exceptions are an excellent foundation for confidently evolving system behavior in these sorts of ways. Plus, I can implement this evolution in the most convenient place for me. I don't have to go to a lot of trouble propagate all down the call stack to where it's thrown; that code can pass it on without knowing what it is. Yes, any of these exception handling blocks is coupled to the exceptions it catches, but higher level blocks aren't, and code in between can pass them on without understanding them -- that's valuable.