DEV Community

Florian Röser
Florian Röser

Posted on • Updated on

Error Handling Bad Practices

This article is a follow-up on my previous 3-step-error-handling article.

In this article I want to show some bad practices in error handling and maybe some quick-fixes, if you stumble
across them in your codebase.

Disclaimer, this blog-post is only a personal opinion and you will need to adjust the recommendations to your specific use-case.

I will handle these antipatterns:

  • 1. Swallowing exceptions
  • 2. Over-logging exceptions
  • 3. Catch generic exceptions
  • 4. (Over-)using checked exceptions
  • 5. Using Exceptions for Control Flow

1. Swallowing exceptions

I regularly stumble across code-bases where exceptions are silently ignored. Maybe the developer was in a hurry or wanted to handle the error cases later on? Anyway, this is a bad practice because if the error occurs:

  • the user will not get any feedback and might think that the operation was successful.
  • there is no stacktrace or error-message to investigate.
  • sometimes there is not even a proper log-entry, making it almost impossible to detect the error.

Bad Example:

  /**
   * Reads some data from another service or repo and returns it.
   * The repo might throw an exception, but we don't care.
   */
  public Mono<String> getHello() {
    try {
      return helloRepository.getHello()
          .map("Hello %s"::formatted);
    } catch (Exception e) {
      // do nothing
      return Mono.just("Hello World");
    }
  }
Enter fullscreen mode Exit fullscreen mode

In this example we might wonder why some users complain about not getting the correct hello-message, but we don't see any errors, we might think that the users are just confused.

Option 1 - Keep the try-catch and handle the error

Handle the error where it occurs, e.g. already in the Repository if possible.
If you need to handle it here, at least log the error and return a proper error-message.

Minimal refactoring

  • log the error (optionally also the stacktrace)
  • return a proper error-message
  public Mono<String> getHello() {
    try {
      return helloRepository.getHello()
          .map("Hello %s"::formatted);
    } catch (Exception e) {
      log.error("Error reading hello data from repository - {}", e.getMessage(), e);
      return Mono.error(new RuntimeException("Internal error reading hello data %s".formatted(e.getMessage()), e));
    }
  }
Enter fullscreen mode Exit fullscreen mode

NOTE:

This might break upstream code since you are now returning an error instead of some default value.

Option 2 - Proper reactive stream refactoring

Better would be to handle the error inside the reactive stream api

  public Mono<String> getHelloHandling() {
    return helloRepository.getHello()
      .map("Hello %s"::formatted)
      // wrap the exception in a RuntimeException with a meaningful message
      .onErrorMap(e -> new RuntimeException("Internal error reading hello data from HelloRepository` %s".
          formatted(e.getMessage()), e));
  }
Enter fullscreen mode Exit fullscreen mode

1b. Swallowing exceptions - with a reactive stream

The pattern from example one also appears in reactive streams:

  public Mono<String> getHello() {
    return helloRepository.getHello()
        .map("Hello %s"::formatted)
        // do nothing on error, just return a default value
        .onErrorReturn("Hello world");
  }
Enter fullscreen mode Exit fullscreen mode

That looks very nice and clean, right? But we will not be able to detect that the error is thrown in the repository!
If there is a default-value, at least an error-log should be written.

Mitigation

As in the previous example we are wrapping the exception in another exception, this time in a custom exception that even makes it easier to detect the specific place where that exception is thrown

  public Mono<String> getHello2() {
    return helloRepository.getHello()
        .map("Hello %s"::formatted)
        .onErrorMap(
            e -> new CustomException("Error reading hello-data from repository - %s".formatted(e.getMessage()), e));
  }
Enter fullscreen mode Exit fullscreen mode

2. Over-logging exceptions

The very opposite of silently dropping exceptions is logging the same exception multiple times, e.g. in the function it occurs, then pass it on to the calling function where again the stacktrace is printed, and in the caller of that again...
This is a bad practice because it confuses the logs with hundreds or thousands of lines of stacktraces without providing any additional meaning.

In my worst example I found the same stacktrace five times in the logs with no meaningful message at all.

Bad Example:

The controller:

@RestController
@AllArgsConstructor
@Slf4j
public class HelloController {

  private final HelloService helloService;

  @GetMapping("/hello")
  public Mono<ResponseEntity<String>> hello() {
    return helloService.getHello()
        .map(ResponseEntity::ok)
        .defaultIfEmpty(ResponseEntity.notFound().build())
        .onErrorResume(e -> {
          log.error("Error:", e);
          return Mono.error(e);
        });
  }
}
Enter fullscreen mode Exit fullscreen mode

And in the Service:

@Service
@AllArgsConstructor
@Slf4j
public class HelloService {

  private final HelloRepository helloRepository;

  /**
   * Reads some data from another service or repo and returns it.
   */
  public Mono<String> getHello() {
    return helloRepository.getHello()
        .map("Hello %s"::formatted)
        .onErrorResume(e -> {
          log.error("Error:", e);
          return Mono.error(e);
        });
  }
}
Enter fullscreen mode Exit fullscreen mode

... and probably in some more places...

Mitigation

This is explained in my previous article 3-step-error-handling, therefore I will not show the code here again, but rather a recommendation:

  • Have a global error-handler that logs the error and returns a proper error-message to the user.
  • In the code, avoid the stacktrace but
    • wrap the exception in a custom exception or runtime exception with a meaningful message
    • log the error-message (not the whole stacktrace)
    • log the stacktrace only in the global error-handler

3. Catch generic exceptions

Catching generic exceptions like Exception or Throwable can lead to unintended behaviour and make debugging quite difficult. It's better to catch specific exceptions.

Bad Example

  public Mono<String> getHello() {
    try {
      return helloRepository.getHello();
    } catch (Exception e) {
      log.error("Error while fetching hello data", e);
      return Mono.empty();
    }
  }
Enter fullscreen mode Exit fullscreen mode

Mitigation

Catch specific exceptions to handle different error scenarios appropriately.

  public Mono<String> getHello() {
    try {
      return helloRepository.getHello();
    } catch (SQLException e) {
      return Mono.error(new HelloDataException("Database error while getting hello-data - %s".formatted(e.getMessage()), e));
    } catch (IOException e) {
      // maybe perform a retry?
      return Mono.error(new HelloDataException("IO error while getting hello-data - %s".formatted(e.getMessage()), e));
    }
  }
Enter fullscreen mode Exit fullscreen mode

and the equivalent using the reactive stream api

  public Mono<String> getHello() {
    return helloRepository.getHello()
        .onErrorMap(SQLException.class, e -> new HelloDataException("Database error while getting hello-data - %s".formatted(e.getMessage()), e))
        .onErrorMap(IOException.class, e -> new HelloDataException("IO error while getting hello-data - %s".formatted(e.getMessage()), e));
  }
Enter fullscreen mode Exit fullscreen mode

4. (Over-)using checked exceptions

In my opinion, checked exceptions where a mistake in Java, they are not very useful and often lead to bad practices and cluttered code.

Bad Example

public void someMethod() throws IOException, SQLException {
  // some code that might throw an exception
}
Enter fullscreen mode Exit fullscreen mode

With checked exceptions you have to deal with them in the calling code, which makes it harder to use other patterns, e.g. functional programming or reactive streams or in spring the global error-handler.

Exception:
Checked exceptions are useful e.g. in library code, where you want to force the user to handle the exception.

Mitigation

Use unchecked exceptions for scenarios where the caller cannot reasonably be expected to recover.

public void someMethod() {
  try {
    // some code that might throw an exception
  } catch (IOException | SQLException e) {
    throw new RuntimeException("An error occurred - %s".formatted(e.getMessage()), e);
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Using Exceptions for Control Flow

Using exceptions for control flow makes the code hard to understand and can lead to performance issues.

Bad Example

try {
  int value = Integer.parseInt("abc");
} catch (NumberFormatException e) {
  // handle the case where the string is not a number
}
Enter fullscreen mode Exit fullscreen mode

Mitigation

Use a regular flow control mechanism like an if-statement.

String value = "abc";
if (value.matches("\\d+")) {
  int number = Integer.parseInt(value);
} else {
  // handle the case where the string is not a number
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this article I showed some bad practices in error handling and how to mitigate them.
I hope you found this article helpful and maybe you can use some of the recommendations in your codebase and in your next refactoring.

Top comments (0)