What to expect when you're excepting Java

Birthing code is not always easy.

Enough puns. Let’s talk about Java exceptions. No matter how hard you try, your code will likely encounter an error and throw an exception (if your language supports exceptions.) It could be anything from unexpected user input to an underlying service outage. An exception will be thrown and it’s important to do something useful with it. That doesn’t mean putting try-catch blocks everywhere or trying to recover everything, in-fact I’ll argue the opposite in a few situations.

This post introduces a few common issues I’ve seen when working with Java code-bases and developers that lead to poor debuggability or other operational pains.

Practices

Stop the catch-log-throw shuffle

This is a common problem in Java code that I see. At multiple levels, developers will catch broad exceptions because there are checked exceptions just to wrap an re-throw or to log to the console.

1
2
3
4
5
6
try {
  // Do a lot of logic
} catch (Exception e) {
  log.warn("Something bad happened!", e);
  throw new RuntimeException("Failed to update.", e);
}

Then this happens at every level in the call stack and soon enough your error log is 10 screens high of call stacks and the exception class has lost all meaning.

Avoid needless wrapping of exceptions. Every wrapped exception adds more noise to understanding the problem. Each wrapped exceptions should provide some additional context or an abstracted exception type.

Take the above example:

  • The outer RuntimeException provides no additional context and the exception class is not specific at all. Make the error clear and direct. Is it important to know which item failed to update?
  • The log statement probably happens at every single catch block. The application log is probably full of useless log statements. Instead, log only at the high point in you call stack.

Logging with Log4j/Slf4j correctly

The following example breaks down a common way developers catch and log exceptions in code. Can you spot the issue? Hint: it has to do with the log format.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ExceptionTest {
  public static void B() {
    throw new IllegalArgumentException("Bad argument");
  }

  public static void A() {
    try {
      B();
    } catch (Exception e) {
      throw new RuntimeException("Something went wrong", e);
    }
  }

  public static void main(String[] args) {
    try {
      A();
    } catch (Exception ex) {
      // Two examples of problematic logging
      log.error("Oh no! an error happened doing {}: {}", "something", ex);
      log.error("Oh no! an error happened doing {}", ex);
    }
  }
}

Running the above code gets the following log statement. Where’s the call stack or the nested exception information?

1
15:21:08.748 [main] ERROR ExceptionTest - Oh no! an error happened doing something: java.lang.RuntimeException: Something went wrong

As it turns out, Log4j has special logic when it’s formatting the log statement. If there are n placeholders and n+1 arguments and the last value extends from Throwable, then it prints out the exception call stack and nested exceptions. If there are n placeholders and n arguments, it doesn’t matter if the last argument is a Throwable, it calls #toString() on all the objects.

The one edge case to this is if you call log.error(“msg”, throwable), then the compiler directly calls Logger#error(String,Throwable), but the end result is the same.

Instead, don’t include a log placeholder for the exception and always pass it as the last parameter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ExceptionTest {
  public static void B() {
    throw new IllegalArgumentException("Bad argument");
  }

  public static void A() {
    try {
      B();
    } catch (Exception e) {
      throw new RuntimeException("Something went wrong", e);
    }
  }

  public static void main(String[] args) {
    try {
      A();
    } catch (Exception ex) {
      log.error("Oh no! an error happened doing {}", "something", ex);
    }
  }
}

When we run the above code, we now get a much more intuitive log statement:

1
2
3
4
5
6
7
8
15:21:47.644 [main] ERROR ExceptionTest - Oh no! an error happened doing something
java.lang.RuntimeException: Something went wrong
	at ExceptionTest.A(ExceptionTest.java:13)
	at ExceptionTest.main(ExceptionTest.java:19)
Caused by: java.lang.IllegalArgumentException: Bad argument
	at ExceptionTest.B(ExceptionTest.java:6)
	at ExceptionTest.A(ExceptionTest.java:11) 
	... 1 more

If you’re using IntelliJ, you can automatically catch issues like this. It’s won’t detect all issues, but it’s a good start.

1
2
3
4
5
// This is detected
log.error("Oh no! an error happened doing {}", ex);

// This isn't detected
log.error("Oh no! an error happened doing {} {}", "something", ex);

Find it in: IntelliJ Inspections: Java | Logging | Number of placeholders does not match number of arguments in logging call.

Include Inner Exceptions

Almost always include the inner exception when throwing an exception in a catch block. Unless you’re careful to include sufficient to explain what happened, you’re more likely to throw an exception that contains not enough information. Special care should be taken when exposing exceptions outside a security boundary, such as to a caller of a service. Log the full details, but then truncate to a minimal amount of information in the response body.

For example, take this:

1
2
3
4
5
try {
  // Do some work
} catch (IllegalArgumentException e) {
  throw new WebApplicationException("Invalid argument");
}

With this, the WebApplicationException does not have an inner exception, so consumers have no context about what happened. In this specific case, it’s critical for the caller to know what argument was invalid so they can fix it.

Include an inner exception when wrapping the exception:

1
2
3
4
5
try {
  // Do some work
} catch (IllegalArgumentException e){
  throw new BadRequestException(e);
}

Don’t accidentally mistake developer mistakes for validation issues

NullPointerExceptions are commonly thrown by validation functions like Lombok’s @NonNull or Guava’s Preconditions. They’re also frequently caused by developer mistakes when you call a method on a null. Unfortunately this can lead to poor exception handling if you assume that NPEs are only thrown by your validation code.

Take the following example. NullPointerExceptions can be thrown in any line of code here. It could mean that the variable something is null and it’s possible handle it or it could mean a developer made a mistake (as I did) and tried to call a method on a null object.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import com.google.common.base.Preconditions;

// [...]

FooBar fooBar = null;

try {
  Preconditions.checkNotNull(something.getValue(), "Validate Something");
  fooBar.callSomething();// Will throw an NPE
} catch (NullPointerException e) {
  LOG.info("something wasn't valid. It's okay though");
  // something must be null and invalid
}

Extract out exception message construction

If you create and throw custom exceptions, you might find yourself writing code like this:

1
2
3
4
5
6
7
8
class MyCustomException extends RuntimeException {
  public MyCustomException(String message, Throwable cause){
    super(message, cause);
  }
}

// ...
throw new MyCustomException(String.format("Something %s happened when doing %s while also doing %s", x, y, z));

If you throw this exception multiple times, then you may end up copying and pasting the String.format code in multiple places. Instead just move the message construction onto the exception class itself:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class MyCustomException extends RuntimeException {
  public MyCustomException(String x, y, z, Throwable cause) {
    super(makeMessage(x, y, z), cause);
  }

  private static String makeMessage(String x, String y, String z){
    return String.format("Something %s happened when doing %s while also doing %s", x, y, z);
  }

}

// ...
throw new MyCustomException(x, y, z);

Structured exceptions

Pretty much every exception I see thrown converts all the problem details into a single giant string message. Strings are terrible at encoding information. Sure a human can read it, but often times code needs to analyze it. Say you’ve got a library that throws a throttling exception when the client calls it too frequently:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void performSomething() {
  if (numCalls > 100) {
    throw new ThrottledException(String.format("Request throttled. Client hit %s calls in %s seconds. Please try again in %s seconds.", ...), 
  }
}

try {
  performSomething();
} catch (ThrottledException e) {
  int pos = e.getMessage().indexOf('try again in' )
  int seconds = e.getMessage().substring(pos + 'try again in '.length);
}

As a caller, I might want to know when I actually can try again. Since this is currently in a string, I’d have to string parse this exception message to find out. That’s horrifying.

Instead, have your exception object property getters for these different facts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class ThrottledException extends RuntimeException {
  private final int numCalls;
  private final int numSeconds;
  private final Instant resetsAt;

  ThrottledException(int numCalls, int numSeconds, Instant resetsAt) {
    super(String.format("Request throttled. Client hit %s calls in %s seconds. Please try again at %s.", numCalls, numSeconds, resetsAt));
    this.numCalls = numCalls;
    this.numSeconds = numSeconds;
    this.resetsAt = resetsAt;
  }

  /**
   * Gets when the caller can resume performing work.
   **/
  public Instant getResetsAt() {
    return resetsAt;
  }

  // ...
}

void performSomething() {
  if (numCalls > 100) {
    throw new ThrottledException(numCalls, numSeconds, Instant.now().plusHours(1));
  }
}

try {
  performSomething();
} catch (ThrottledException e) {
  e.getResetsAt(); // Now I know when to check again
}

RFC9457 is a good resource on how to apply this style of error structuring to an HTTP endpoint.

Checked vs Unchecked Exception

Checked exceptions are exceptions which are checked by the compiler, if they are being explicity thrown or caught. The class Exception and any subclasses that are not also subclasses of RuntimeException are checked exceptions. Checked exceptions need to be declared in a method or constructor’s throws clause if they can be thrown by the execution of the method or constructor and propagate outside the method or constructor boundary.

Examples of checked exceptions that we might have seen are IOException, JSONParseException.

Every exception which is subclass of the RuntimeException class is an unchecked exception and not checked by the compiler. A common example of this is the NullPointerException.

Which type of exception should we use when creating our own exceptions?

Almost always the recommendation is to create an unchecked exception.

Checked exceptions, if not modeled correctly, adds unnecessary burden on the developers. If you throw a checked exception in your code and it can only be appropriately handled, say 3 - 4 layers up, it has to be added in each method’s declaration. Imagine adding/removing a checked exception in the code which would require layers of code to be refactored. Checked exceptions also do not add any value in our service interfaces as the actual clients will never catch it.

The worst offender for useless checked exception is Charset#ofName which throws a checked IllegalCharsetNameException. Every time I’ve used it, I’ve always passed the static string UTF-8 and if it’s missing, I’m screwed.

Imagine, every time you have to deserialize or serialize a file you end up with this the following code and this untestable catch block that reduces my code coverage.

1
2
3
4
5
try {
  Charset.ofName('UTF-8');
} catch (IllegalCharsetNameException e) {
  throw new RuntimeException("When would this ever happen?", e)
}

References

Copyright - All Rights Reserved

Comments

Comments are currently unavailable while I move to this new blog platform. To give feedback, send an email to adam [at] this website url.