1. Overview

In this tutorial, we’ll take a look at the default error handling in the Play Framework. Additionally, we’ll implement a custom error handler.

First, we’ll define a Play controller that returns various errors to see how errors are automatically handled. Then, we’ll provide a custom error handler and look at its limitations.

2. Play Framework Errors

A Play controller returns an error in three cases:

  • a given request is invalid, and it fails validation (client-side errors)
  • the application code throws an exception (server-side errors)
  • we explicitly return an error response in the Scala code (domain-specific errors)

3. Controller Errors

Let’s prepare a controller class that returns all three of those errors to see what happens and experiment with error handlers.

First, we’ll define a new controller class with five methods to return various kinds of errors:

class ErrorDemoController @Inject()(
  val controllerComponents: ControllerComponents
) extends BaseController {
  def notFound(): Action[AnyContent] = Action {
  def exception(): Action[AnyContent] = Action {
    throw new RuntimeException("Pretend that we have an application error.")
    Ok // We add this line just to make the returned type match the expected type

  def internalError(): Action[AnyContent] = Action {

  def badRequest(): Action[AnyContent] = Action {

After defining the controller, we’ll add the new methods as route handlers in the routes file:

GET     /errors/notfound             controllers.ErrorDemoController.notfound()
GET     /errors/exception           controllers.ErrorDemoController.exception()
GET     /errors/internalerror       controllers.ErrorDemoController.internalError()
GET     /errors/badRequest          controllers.ErrorDemoController.badRequest()

4. Default Error Handling

Now, let’s access the endpoints one by one to see what happens. First, we’ll demonstrate how the Play Framework handles an application error.

4.1. Handling a Scala Exception

Let’s open http://localhost:9000/errors/exception in the browser. In the code, we see that the application throws a RuntimeException to simulate a failure in the application code. When we run the Play Framework in development mode, it returns an error page with the application code and highlights the line that caused the problem:

play framework error1

Of course, showing the application code to the users is an unacceptable security risk, so in production mode, the error page contains less information:

play framework error2

As promised in the error message, when we take a look at the application logs, we’ll see a line containing the error id and the stack trace:

! @7ibgcn351 - Internal server error, for (GET) [/errors/exception] ->
play.api.UnexpectedException: Unexpected exception[RuntimeException: Pretend that we have an application error.]
    at play.api.http.HttpErrorHandlerExceptions$.throwableToUsefulException(HttpErrorHandler.scala:355)
Caused by: java.lang.RuntimeException: Pretend that we have an application error.
    at controllers.ErrorDemoController.$anonfun$exception$1(ErrorDemoController.scala:13)

4.2. Explicitly Returning an InternalServerError, NotFound, and BadRequest

When we open the http://localhost:9000/errors/internalerror URL in the browser, our application code explicitly returns the status that indicates an internal server error. In this case, we won’t see the error page provided by the Play Framework. Instead, the browser receives the 500 HTTP status code and displays its error page.

The same happens when we open the http://localhost:9000/errors/notfound and http://localhost:9000/errors/badrequest pages. The error statuses explicitly returned from the controller code aren’t intercepted by the error handler.

4.3. Getting a Real NotFound Page

Of course, when we access a non-existing page – for example, http://localhost:9000/this_is_not_here – we’re going to see a default NotFound error page (again, only in the development mode):

play framework error3

To handle this scenario and redirect to a different page when the requested page doesn’t exist, we can define a custom error handler.

5. Custom Error Handler

Creating a custom error handler starts with implementing a new error handler class:

class CustomErrorHandler extends HttpErrorHandler {
  def onClientError(request: RequestHeader, statusCode: Int, message: String): Future[Result] = {
    if (statusCode == NOT_FOUND) {
      // works only when we access a not existing page, not when we return NotFound on purpose
    } else {
        Status(statusCode)("A client error occurred.")

  def onServerError(request: RequestHeader, exception: Throwable): Future[Result] = {
      InternalServerError("A server error occurred: " + exception.getMessage)

Note that we want to redirect the user to the /errors/noerror page in the case of a non-existent page. Therefore, we have to add a new method to the ErrorDemoController:

def noError(): Action[AnyContent] = Action {

And we’ll add a new entry to the routes file:

GET /errors/noerror controllers.ErrorDemoController.noerror()

To use the custom handler, we have to overwrite it in the application.conf file:

play.http.errorHandler = "errors.CustomErrorHandler"

6. Testing a Custom Error Handler

Now, let’s open the http://localhost:9000/errors/exception page in the browser. Instead of seeing a red page with an error message, and highlighted application code, we see a white page with the message: “A server error occurred”. This means that the onServerError method handled the error and generated the response.

Like the default error handler, the custom handler will not intercept a server error if we explicitly return it from the application code. When we open the http://localhost:9000/errors/internalerror page, the browser will receive status 500 and display its error page.

We can observe the same behavior when we test client-side errors. Opening http://localhost:9000/errors/notfound generates a response with the 404 HTTP status, and the browser displays a “Page not found” message. When we open the http://localhost:9000/this_is_not_here page, however, the custom code of the onClientError method runs, and we’re redirected to the http://localhost:9000/errors/noerror page.

6.1. Writing an Automated Test

If we write unit tests for our route controllers, the framework won’t use the custom error handler during the tests. It behaves in that way to ensure that we can test the controller code, not the error handler that intercepts the exceptions.

To test the error handler itself, we must implement separate unit tests that use the onClientError or onServerError methods directly:

class CustomErrorHandlerSpec extends PlaySpec with GuiceOneAppPerTest with Injecting with Eventually {
  "CustomErrorHandler" should {
    "redirect when a page has not been found" in {
      val objectUnderTest = new CustomErrorHandler()
      val request = FakeRequest(GET, "/fake")
      val statusCode = StatusCodes.NotFound
      val message = ""

      val responseFuture = objectUnderTest.onClientError(request, statusCode.intValue, message)

      eventually {
        status(responseFuture) mustBe StatusCodes.SeeOther.intValue

7. Conclusion

In this article, we’ve tested the default error handling in Play Framework, changed its behavior using a custom error handler, and demonstrated that explicitly returning an error status doesn’t trigger the error handler.

As usual, the source code can be found over on GitHub.