Igor Khromov blog

Customizing embedded Jetty Server responses to return 404, 502, 503 errors in JSON

1. Problem

Very often when we building RESTful web-services we want Jetty to return server-related errors in JSON and not as HTML Jetty’s default pages like:

<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<title>Error 404 Not Found</title>
</head>
<body><h2>HTTP ERROR 404</h2>
<p>Problem accessing /sdf. Reason:
<pre>    Not Found</pre></p><hr><a href="http://eclipse.org/jetty">Powered by Jetty:// 9.4.19.v20190610</a><hr/>

</body>
</html>

To show how we can achieve that we can build a very simple RESTful endpoint with SpringMVC.

1.1. Create a simple maven project

You can create a Maven project with IDE you are using, I created it with IntelliJ IDEA like described here: https://igorkhromov.com/2019/07/22/create-a-simple-maven-project-with-intellij-idea

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.igorkhromov</groupId>
    <artifactId>jetty-display-errors-in-json</artifactId>
    <packaging>war</packaging>
    <version>1.0.0</version>

    <properties>
        <jdk.version>1.8</jdk.version>
        <spring.version>5.1.5.RELEASE</spring.version>
        <jetty.version>9.4.19.v20190610</jetty.version>
        <jetty-maven-plugin.version>9.4.19.v20190610</jetty-maven-plugin.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.9.9.3</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-server</artifactId>
            <version>${jetty.version}</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-servlet</artifactId>
            <version>${jetty.version}</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-webapp</artifactId>
            <version>${jetty.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

1.2. Add Main.java

Create Main.java file in the `src` folder with Jetty config:

Main.java

public class Main {

    public static void main(String[] args) throws Exception {
        String webXmlPath = new File(".").getAbsolutePath()
                + "/src/main/webapp";

        Server server = new Server();

        ServerConnector connector = new ServerConnector(server);
        connector.setPort(8080);
        server.setConnectors(new Connector[] { connector });

        WebAppContext context = new WebAppContext();
        context.setContextPath("/");
        context.setResourceBase(webXmlPath);
        context.setParentLoaderPriority(true);

        HandlerCollection handlers = new HandlerCollection();
        handlers.setHandlers(new Handler[] { context });
        server.setHandler(handlers);

        server.start();
        server.join();
    }
}

1.3. Create web.xml with SpringMVC app config

web.xml

<web-app id="WebApp_ID" version="2.4"
	xmlns="http://java.sun.com/xml/ns/j2ee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee 
	http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd">

	<display-name>Spring Web MVC Application</display-name>

	<context-param>
		<param-name>contextConfigLocation</param-name>
		<param-value>/WEB-INF/application-context.xml</param-value>
	</context-param>

	<servlet>
		<servlet-name>dispatcher</servlet-name>
		<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
		<init-param>
			<param-name>contextConfigLocation</param-name>
			<param-value>/WEB-INF/web-context.xml</param-value>
		</init-param>
		<load-on-startup>1</load-on-startup>
	</servlet>

	<servlet-mapping>
		<servlet-name>dispatcher</servlet-name>
		<url-pattern>/api/v1.0/</url-pattern>
	</servlet-mapping>

	<listener>
		<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
	</listener>
</web-app>

I have added more two files application-context.xml and web-context.xml to setup the SpringMVC app context.

application-context.xml

<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xmlns="http://www.springframework.org/schema/beans"
       xsi:schemaLocation="http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

    <mvc:annotation-driven/>

</beans>

web-context.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns="http://www.springframework.org/schema/beans"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">

    <import resource="application-context.xml"/>

    <context:component-scan base-package="com.igorkhromov"/>
</beans>

1.4. Create a simple SpringMVC REST controller

AuthorController.java

@RestController
public class AuthorController {

    @GetMapping("/author")
    public ResponseEntity<User> getRandomInteger() throws Exception {
        return new ResponseEntity<>(new User("Jack", "London"), HttpStatus.OK);
    }
}

1.5. Run the application to see the 404 error page

We have mapped our SpringMVC app to URL that will start with /api/v1.0/ (line #25 in web.xml), so our request to controller mapped to “/author” will look like “http://localhost:8080/api/v1.0/author”.

All other requests to different URLs will return 404 HTML page mentioned earlier.

1.6. Overriding Jetty default HTML error responses

We can change Jetty’s behaviour with overriding ErrorHandler class (https://www.eclipse.org/jetty/javadoc/current/org/eclipse/jetty/server/handler/ErrorHandler.html), that Jetty uses show errors.

If you will check WebAppContext (line #13 of Main.java) default constructor you can find this code:

WebAppContext.java

public WebAppContext()
    {
        this(null,null,null,null,null,new ErrorPageErrorHandler(),SESSIONS|SECURITY);
    }

So by default Jetty uses ErrorPageErrorHandler class for handling errors.

We can easily override the behavior of this class just subclassing it.

ErrorHandler.java

public class ErrorHandler extends ErrorPageErrorHandler {

    /*
        Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content
        https://tools.ietf.org/html/rfc7231
    */

    private static final String ERROR_404_MESSAGE = "Target resource not found";

    private static final String ERROR_501_MESSAGE = "Server functionality to process request is not implemented";

    private static final String ERROR_502_MESSAGE = "Server cannot proxy request";

    private static final String ERROR_503_MESSAGE = "Server is currently unable to handle the request";

    private static final String ERROR_504_MESSAGE = "Server did not receive a timely response from an upstream server";

    private static final String ERROR_UNEXPECTED_MESSAGE = "Unexpected error occurs";

    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Override
    protected void generateAcceptableResponse(Request baseRequest, HttpServletRequest request, HttpServletResponse response, int code, String message, String mimeType)
            throws IOException
    {
        baseRequest.setHandled(true);
        Writer writer = getAcceptableWriter(baseRequest, request, response);
        if (null != writer) {
            response.setContentType(MimeTypes.Type.APPLICATION_JSON.asString());
            response.setStatus(code);
            handleErrorPage(request, writer, code, message);
        }
    }

    @Override
    protected Writer getAcceptableWriter(Request baseRequest, HttpServletRequest request, HttpServletResponse response)
            throws IOException
    {
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        return response.getWriter();
    }

    @Override
    protected void writeErrorPage(HttpServletRequest request, Writer writer, int code, String message, boolean showStacks)
            throws IOException
    {
        try {
            writer.write(MAPPER.writeValueAsString(new Errors(getMessage(code))));
        }
        catch (Exception e) {
            // Log if needed
        }
    }

    private String getMessage(int code) {
        switch (code) {
            case 404 : return ERROR_404_MESSAGE;
            case 501 : return ERROR_501_MESSAGE;
            case 502 : return ERROR_502_MESSAGE;
            case 503 : return ERROR_503_MESSAGE;
            case 504 : return ERROR_504_MESSAGE;
            default  : return ERROR_UNEXPECTED_MESSAGE;
        }
    }
}

Error messages constructed from RFC specification: https://tools.ietf.org/html/rfc7231. You can your own ones.

1.7. Set error handler to server context.

We need to tell Jetty that this new class is a new error handler.

Add the following string to Main.java class (after line #13):

context.setErrorHandler(new ErrorHandler());

Double-check which ErrorHandler you added, it should be one created by you. In this example, it’s located in a package “com.igorkhromov”.

1.8. Handling errors only for specific URI

If you have RESTful API and several different servlets on the same server, you can check if the request URI starts with you desired one (/api/ in this example). So you will have errors in JSON format only for REST endpoints.

Code sample for changed ErrorHandler:

ErrorHandler.java

public class ErrorHandler extends ErrorPageErrorHandler {

    /*
        Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content
        https://tools.ietf.org/html/rfc7231
    */

    private static final String ERROR_404_MESSAGE = "Target resource not found";

    private static final String ERROR_501_MESSAGE = "Server functionality to process request is not implemented";

    private static final String ERROR_502_MESSAGE = "Server cannot proxy request";

    private static final String ERROR_503_MESSAGE = "Server is currently unable to handle the request";

    private static final String ERROR_504_MESSAGE = "Server did not receive a timely response from an upstream server";

    private static final String ERROR_UNEXPECTED_MESSAGE = "Unexpected error occurs";

    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Override
    protected void generateAcceptableResponse(Request baseRequest, HttpServletRequest request, HttpServletResponse response, int code, String message, String mimeType)
            throws IOException
    {
        if (isRestRequest(request)) {
            baseRequest.setHandled(true);
            Writer writer = getAcceptableWriter(baseRequest, request, response);
            if (null != writer) {
                response.setContentType(MimeTypes.Type.APPLICATION_JSON.asString());
                response.setStatus(code);
                handleErrorPage(request, writer, code, message);
            }   
        } 
        else {
            super.generateAcceptableResponse(baseRequest, request, response, code, message, mimeType);
        }
    }

    @Override
    protected Writer getAcceptableWriter(Request baseRequest, HttpServletRequest request, HttpServletResponse response)
            throws IOException
    {
        if (isRestRequest(request)) {
            response.setCharacterEncoding(StandardCharsets.UTF_8.name());
            return response.getWriter();   
        }
        else {
            return super.getAcceptableWriter(baseRequest, request, response);
        }
    }

    @Override
    protected void writeErrorPage(HttpServletRequest request, Writer writer, int code, String message, boolean showStacks)
            throws IOException
    {
        if (isRestRequest(request)) {
            try {
                writer.write(MAPPER.writeValueAsString(new Errors(getMessage(code))));
            }
            catch (Exception e) {
                // Log if needed
            }
        }
        else {
            super.writeErrorPage(request, writer, code, message, showStacks);
        }
    }

    private boolean isRestRequest(HttpServletRequest request) {
        return request.getServletPath().startsWith("/api/");
    }

    private String getMessage(int code) {
        switch (code) {
            case 404 : return ERROR_404_MESSAGE;
            case 501 : return ERROR_501_MESSAGE;
            case 502 : return ERROR_502_MESSAGE;
            case 503 : return ERROR_503_MESSAGE;
            case 504 : return ERROR_504_MESSAGE;
            default  : return ERROR_UNEXPECTED_MESSAGE;
        }
    }
}

We have added isRestRequest function to check if the request is API request.

So now we can return errors in JSON.

You can extend the error list in getMessage (line #74) function if needed.

1.9. Github project

You can find the full project code at Github: https://github.com/xrom888/blog_jetty-display-errors-in-json.

Clone repo:

git clone git@github.com:xrom888/blog_jetty-display-errors-in-json.git