Exception Handling and Error Pages

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Jakarta Faces 4.1 (API jakarta.faces-api 4.1.2, part of Jakarta EE 11), Eclipse Mojarra 4.1.17 and Apache MyFaces Core 4.1.4, with Java 17 or later, OmniFaces 5.5.5 and the documentation of PrimeFaces 16. The change lists of the 4.1 and 4.0 specifications have no entry about the ExceptionHandler, so what follows also describes Faces 4.0 (nothing was run on 4.0); the older releases are covered in What Changed. The examples were compiled and deployed in a scratch Bookshelf WAR on Apache Tomcat 11.0.26 with Weld 6.0.4.Final, once with Mojarra and once with MyFaces (and, for the OmniFaces parts, with omnifaces-5.5.5.jar added to the WAR). The server side was driven with curl and the browser side with headless Google Chrome controlled through the DevTools protocol. The HTTP statuses, the response bodies, the log lines and every "what happened" statement come from those runs (checked on 2026-10-08); the two implementations disagree in several places, and Mojarra and MyFaces collects them. The PrimeFaces handler (PrimeFaces Ajax Error Handling) is described from its documentation, plus the run reported on the PrimeFaces Ajax forms page.

Every application throws exceptions. A database is down, a bean has a bug, a user opens a page whose session expired. What the user then sees decides two things at once. For the user, it decides whether the application feels broken or merely unlucky. For the operator, it decides whether the failure leaks the application: a raw container error page shows the exception message, the names of the classes and the stack frames, which tell an attacker which framework versions run, which internal hosts the code talks to, and where the code is fragile. This page explains where Faces sends exceptions, how to take control of them, and how to make sure that in Production a user sees a short fixed message and a reference number, while the full detail goes to the server log.

The Exception Model

From Throw to Handler

Faces does not let an exception travel up the call stack to the container directly. Whenever code inside the lifecycle throws (an action method, a getter, a listener, a converter that fails in an unexpected way, a template that cannot be built), the runtime catches the exception and publishes an ExceptionQueuedEvent for it. The event is delivered to the one listener that the event context names, the request’s ExceptionHandler, which stores it in a queue. At the end of every lifecycle phase the runtime calls ExceptionHandler.handle(), and that method decides what happens to the queued events.

flowchart TD A[Exception thrown while a phase runs] --> B{Expected kind?} B -- "ValidatorException, ConverterException, AbortProcessingException" --> C[Handled by the component, no handler involved] B -- Anything else --> D[ExceptionQueuedEvent published] D --> E["ExceptionHandler.processEvent queues it"] E --> F["handle() is called at the end of the phase"] F --> G{Which handler is installed?} G -- Default, non-Ajax --> H[Root cause rethrown as ServletException] H --> I[Container matches an error-page or prints its own report] G -- Default, Ajax --> J[error element written to the partial-response] G -- Your wrapper --> K[Log it, redirect or render your own page]

The default handler of a non-Ajax request takes the first queued event, unwraps it with getRootCause(), wraps the result in a ServletException and throws it, so that an <error-page> of the deployment descriptor, or else the container’s default error page, answers (this is the specification’s description of the default behaviour). The default handler of an Ajax request writes the exception into the partial response instead. Your own handler can do either of those things differently, or something else, and that is what the rest of the page is about.

An exception can also be queued by hand: code that catches an exception and wants the application’s central policy applied to it, but also wants the current phase to continue, publishes the event itself (Queueing an Exception by Hand).

Where It Sits in the Lifecycle

The lifecycle executes each phase, then calls handle() of the request’s handler. A probe handler logged the calls, with the same result on Mojarra and on MyFaces:

  • A plain GET of a page: handle() after Restore View and after Render Response (two calls).

  • A postback, with or without Ajax: after each of the six phases (six calls); an Ajax request shows the same six.

Exceptions thrown by the code of a phase reach the handler at the end of that phase, so a failure in Invoke Application is handled before Render Response starts, and a failure inside Render Response is handled at the end of rendering, with part of the response possibly already written. Exceptions thrown by PhaseListener methods are published too, with the extra attributes that tell whether the exception came from the beforePhase or the afterPhase part (What an Event Tells You). The lifecycle chapter describes the phases (the six phases) and phase listeners.

Two phase-specific rules come from the API documentation. If handle() runs in Render Response the recovery options are more limited: calling NavigationHandler.handleNavigation() there is not valid, and since Faces 2.3 the specification says so explicitly. A handler that wants to send the user to an error view from a rendering failure must redirect through the ExternalContext instead, which is what the example below does (the navigation handler is for outcomes returned by actions).

Exceptions That Are Not Handled Here

The specification lists exceptions that are part of normal processing and that the ExceptionHandler must not handle: a ValidatorException and a ConverterException where the specification says they are thrown or caught (they become messages and make the component invalid), the MissingResourceException of f:loadBundle, an exception thrown by a @PreDestroy method of a bean (it is logged and not rethrown) and, since the 2.2 change list, AbortProcessingException.

The last one deserves a test, because the two implementations do not behave alike. With no custom handler installed, AbortProcessingException("stop here") thrown from:

Thrown from Mojarra 4.1.17 MyFaces 4.1.4

An actionListener method

HTTP 200, page redisplayed

HTTP 200, page redisplayed

The action method itself

HTTP 500 (an Ajax request gets an error element naming AbortProcessingException)

HTTP 200, page redisplayed

With a custom handler installed (ExceptionHandlerFactory and ExceptionHandlerWrapper), the event arrived in handle() on both implementations, from the action method and from the listener alike: the implementations queue it, and the default handler is what ignores it. A probe handler printed the type that arrived. On MyFaces it was AbortProcessingException in both cases. On Mojarra it was AbortProcessingException for the listener and a FacesException wrapping it for the action method, which is why Mojarra’s default handler, which ignores the bare exception, turned the wrapped one into an HTTP 500. A custom handler must therefore unwrap before it decides, and must decide to ignore it explicitly, or every aborted request becomes an error page. The handler of A Complete Handler does.

What Happens With No Configuration

A Production application needs a policy, because the defaults are not one. The Bookshelf scratch application has a bean whose methods fail on purpose (LabBean, below) and the page lab.xhtml with a plain and an Ajax button for each of them. The most instructive failure is a lookup of an unknown book, because the exception message names an internal database:

org.example.bookshelf.model.BookNotFoundException
package org.example.bookshelf.model;

public class BookNotFoundException extends RuntimeException {

    private final long id;

    public BookNotFoundException(long id) {
        super("No book with id " + id + " in table BOOKS (jdbc:postgresql://db.internal:5432/bookshelf)");
        this.id = id;
    }

    public long getId() {
        return id;
    }
}
The method added to BookService
    public Book require(long id) {
        return books.stream()
                .filter(b -> b.id() == id)
                .findFirst()
                .orElseThrow(() -> new BookNotFoundException(id));
    }
org.example.bookshelf.web.LabBean (a bean that fails on purpose; test code, not for production)
package org.example.bookshelf.web;

import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.event.AbortProcessingException;
import jakarta.faces.event.ActionEvent;
import jakarta.faces.event.ExceptionQueuedEvent;
import jakarta.faces.event.ExceptionQueuedEventContext;
import jakarta.faces.event.PhaseEvent;
import jakarta.faces.event.PhaseId;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.example.bookshelf.model.BookNotFoundException;
import org.example.bookshelf.service.BookService;

@Named
@RequestScoped
public class LabBean {

    @Inject
    private FacesContext faces;
    @Inject
    private BookService books;

    public void runtime() {
        throw new IllegalStateException("The review service is down");
    }

    public void missingBook() {
        books.require(99);
    }

    public void abort() {
        throw new AbortProcessingException("stop here");
    }

    public void abortListener(ActionEvent event) {
        throw new AbortProcessingException("stop here");
    }

    public void afterPhase(PhaseEvent event) {
        if (event.getPhaseId() == PhaseId.PROCESS_VALIDATIONS) {
            throw new IllegalStateException("phase listener failure");
        }
    }

    public void invalidate() {
        faces.getExternalContext().invalidateSession();
    }

    public void ok() {
        faces.addMessage(null, new FacesMessage("All good"));
    }

    public void queueAndContinue() {
        try {
            books.require(99);
        } catch (BookNotFoundException e) {
            faces.getApplication().publishEvent(faces, ExceptionQueuedEvent.class,
                    new ExceptionQueuedEventContext(faces, e));
        }
        faces.addMessage(null, new FacesMessage("The action went on after queueing the exception"));
    }

    public String getExplode() {
        return String.valueOf(books.require(99));
    }
}
lab.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<h:head><title>Lab</title></h:head>
<h:body>
  <h:messages id="messages" globalOnly="true"/>
  <h:outputText id="expiredNote" value="Your session expired; the page was reloaded." rendered="#{param.expired eq 'true'}"/>
  <h:form id="lab">
    <h:commandButton id="runtime" value="Runtime" action="#{labBean.runtime}"/>
    <h:commandButton id="missing" value="Missing" action="#{labBean.missingBook}"/>
    <h:commandButton id="abort" value="Abort" action="#{labBean.abort}"/>
    <h:commandButton id="queue" value="Queue" action="#{labBean.queueAndContinue}"/>
    <h:commandButton id="abortl" value="AbortL" actionListener="#{labBean.abortListener}" action="#{labBean.queueAndContinue}"/>
    <h:commandButton id="ok" value="Ok" action="#{labBean.ok}"/>
    <h:commandButton id="aruntime" value="ARuntime" action="#{labBean.runtime}"><f:ajax execute="@form" render="@form :messages"/></h:commandButton>
    <h:commandButton id="amissing" value="AMissing" action="#{labBean.missingBook}"><f:ajax execute="@form" render="@form :messages"/></h:commandButton>
    <h:commandButton id="aabortl" value="AAbortL" actionListener="#{labBean.abortListener}" action="#{labBean.queueAndContinue}"><f:ajax execute="@form" render="@form :messages"/></h:commandButton>
    <h:commandButton id="aabort" value="AAbort" action="#{labBean.abort}"><f:ajax execute="@form" render="@form :messages"/></h:commandButton>
    <h:commandButton id="aqueue" value="AQueue" action="#{labBean.queueAndContinue}"><f:ajax execute="@form" render="@form :messages"/></h:commandButton>
    <h:commandButton id="aok" value="AOk" action="#{labBean.ok}"><f:ajax execute="@form" render="@form :messages"/></h:commandButton>
    <h:commandButton id="ainval" value="AInval" action="#{labBean.invalidate}"><f:ajax execute="@this" render=":messages"/></h:commandButton>
  </h:form>
</h:body>
</html>

Clicking Missing (the missingBook action) with nothing configured, once in Production and once in Development:

Case Mojarra 4.1.17 MyFaces 4.1.4

Production, plain request

HTTP 500 with Tomcat’s own error report: title HTTP Status 500, the exception message with the internal JDBC URL, and the stack frames (including the application’s class names)

The same Tomcat report, with the root-cause chain through ContextAwareELException

Production, Ajax request, HTTP 200

error-name class org.example.bookshelf.model.BookNotFoundException; the message is replaced by See your server log for more information

error-name org.apache.myfaces.view.facelets.el.ContextAwareELException; the full message with the JDBC URL

Development, plain request

HTTP 500 and a Faces debugging page titled Error - <message>, with Stack Trace, Component Tree and Scoped Variables sections

The same kind of page (MyFaces: Error - jakarta.el.ELException: ...)

Development, Ajax request

error-name and the full message

error-name and the full message

A failure while rendering (render-fail.xhtml), Production

HTTP 500, Tomcat report with message and frames

HTTP 500 and only the part of the page rendered before the failure (132 bytes in the run); no report

The container’s page is the dangerous default. Tomcat’s ErrorReportValve has the attributes showReport and showServerInfo, both true by default; showReport controls whether the report with the error message and the stack trace is sent to the client. The cure on every container is the same, and is declared in the application: an <error-page> (Error Pages in web.xml). Setting showReport="false" on the valve is a Tomcat-only hardening that belongs in the server configuration, in addition and not instead.

The Development Error Pages

In the Development project stage both implementations replace the failing response with a debugging page. The pages list the stack trace, the component tree and "Scoped Variables": the request parameters (the run showed lab:missing=missing and the form’s hidden parameter), request, view and session attributes. In an application with a login form the request parameters are what the user typed into it. That is why Development must never reach a production server, and why the project stage is the first thing to check when an error page shows more than a sentence (the configuration page covers jakarta.faces.PROJECT_STAGE and ProjectStage in detail). MyFaces can switch its page off with the context parameter org.apache.myfaces.ERROR_HANDLING set to false: in the run, a Development request then produced Tomcat’s report (HTTP 500) instead of the Faces page. Mojarra has no such switch in the documentation consulted.

Error Pages in web.xml

The Servlet mechanism is the foundation, and the only one that works for exceptions that Faces cannot handle (a failure before the FacesServlet is reached, a failure of the error handling itself). An <error-page> maps a status code or an exception type to a location inside the application. The schema of web-app_6_1.xsd states that declarations are unique per exception type and per status code, and that an error-page with neither an error-code nor an exception-type is a default error page.

web.xml (the error-page entries, inside the web-app element)
  <error-page>
    <exception-type>jakarta.faces.application.ViewExpiredException</exception-type>
    <location>/error/expired.xhtml</location>
  </error-page>
  <error-page>
    <exception-type>org.example.bookshelf.model.BookNotFoundException</exception-type>
    <location>/error/not-found.xhtml</location>
  </error-page>
  <error-page>
    <error-code>404</error-code>
    <location>/error/not-found.xhtml</location>
  </error-page>
  <error-page>
    <error-code>500</error-code>
    <location>/error/server-error.xhtml</location>
  </error-page>
The default error page, an alternative to listing codes and types
<error-page>
  <location>/error/server-error.xhtml</location>
</error-page>

With the default form, requests for nothing.xhtml (404) and for /WEB-INF/web.xml (404) and an action that threw all produced the one page, on both implementations. It is convenient and it is blunt: a page that is right for a server fault is wrong for a missing page.

The location is a normal Faces view. It is rendered through the FacesServlet (the view mapping applies; with *.xhtml mapping the example works as written). The specification tells the lifecycle that a request which is the container’s attempt to display an error page must not be treated as a postback, even if it is one, so the error view is built fresh and can contain forms and links.

error/server-error.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<f:metadata>
  <f:event type="preRenderView" listener="#{errorBean.markAsServerError}"/>
</f:metadata>
<h:head><title>Something went wrong</title></h:head>
<h:body>
  <h1>Something went wrong</h1>
  <p>The error has been recorded. If you contact support, quote the reference
     <strong><h:outputText value="#{errorBean.reference}"/></strong>.</p>
  <p><a href="#{request.contextPath}/lab.xhtml">Back to the catalogue</a></p>
</h:body>
</html>
org.example.bookshelf.web.ErrorBean
package org.example.bookshelf.web;

import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.servlet.http.HttpServletRequest;

@Named
@RequestScoped
public class ErrorBean {

    @Inject
    private HttpServletRequest request;
    @Inject
    private FacesContext faces;

    public void markAsServerError() {
        faces.getExternalContext().setResponseStatus(500);
    }

    public String getReference() {
        String ref = request.getParameter("ref");
        return ref != null && ref.matches("[0-9a-f]{8}") ? ref : "";
    }
}
error/expired.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html">
<h:head><title>Page expired</title></h:head>
<h:body>
  <h1>Your session expired</h1>
  <p>Nothing was saved. <a href="#{request.contextPath}/lab.xhtml">Start again</a>.</p>
</h:body>
</html>

The page shows a fixed text and the reference number, nothing else. The reference is validated against the pattern the handler generates (eight hex digits), so an arbitrary query string is never echoed, and it goes through h:outputText, which escapes. The page marks itself HTTP 500 in a preRenderView listener; without it, the response to the redirect (ExceptionHandlerFactory and ExceptionHandlerWrapper) would be a 200 page named "Something went wrong", which monitoring tools cannot count as an error. A container-driven forward already has status 500 and the call is harmless; a page shared with a 404 mapping must not set it (the error-page default above did, in the run, for a missing page).

What the Container Tells the Error Page

When the container forwards to the location it sets request attributes: jakarta.servlet.error.status_code, jakarta.servlet.error.exception_type, jakarta.servlet.error.exception, jakarta.servlet.error.message and jakarta.servlet.error.request_uri. A debugging page in the scratch application read them with #{requestScope['jakarta.servlet.error.exception_type']}, and showed that what the container sees depends on the implementation (next section). For users, the attributes are developer information: show the status code if you like, never the message or the exception, and escape everything that comes from the request (the request_uri is whatever the client sent).

Which error-page Matches

Tomcat 11.0.26 matched the exception that Faces threw to the container, with these results (Production, all with an error-code 500 fallback, ViewExpiredException, BookNotFoundException and 404 mapped to their own pages):

Request Mojarra 4.1.17 MyFaces 4.1.4

Postback with a view state of another session

expired.xhtml

expired.xhtml

missingBook (a BookNotFoundException in an action)

not-found.xhtml (matched by type)

server-error.xhtml (the 500 fallback)

A BookNotFoundException thrown by a getter while rendering

not-found.xhtml

server-error.xhtml

IllegalStateException in an action

server-error.xhtml

server-error.xhtml

An unknown .xhtml

not-found.xhtml (404)

not-found.xhtml (404)

Mojarra’s default handler rethrows the root cause, so jakarta.servlet.error.exception_type was the application’s own exception class and an exception-type mapping for it matched. MyFaces delivered a ContextAwareELException (the tag exception wrapper of its Facelets EL; exception_type in the debug page), and getRootCause() of the default handler returned it unchanged, because the specification unwraps only the exact classes FacesException and jakarta.el.ELException: the probe handler printed defaultRootCause=...ContextAwareELException for every case. Mapping an application exception type therefore works on Mojarra and not on MyFaces, unless you set the context parameter org.apache.myfaces.WRAP_TAG_EXCEPTIONS_AS_CONTEXT_AWARE to false. In that run MyFaces delivered the unwrapped BookNotFoundException and the mapping matched (and the Ajax error-name became the plain class name, like Mojarra’s). ViewExpiredException is not wrapped and matched on both.

A portable policy maps ViewExpiredException and the status codes (404, 500) and leaves application exception types to a handler that you write (ExceptionHandlerFactory and ExceptionHandlerWrapper) or to the OmniFaces unwrapping filter (OmniFaces FullAjaxExceptionHandler).

java.lang.Throwable Hides the More Specific Mappings

A mapping for java.lang.Throwable is a catch-all, and in the run it won over the more specific mappings. The FacesServlet throws a ServletException; the result is consistent with Tomcat matching that wrapper (whose hierarchy reaches Throwable) before it looks at the root cause (Tomcat’s source was not consulted). With

<error-page>
  <exception-type>jakarta.faces.application.ViewExpiredException</exception-type>
  <location>/error/expired.xhtml</location>
</error-page>
<error-page>
  <exception-type>java.lang.Throwable</exception-type>
  <location>/error/server-error.xhtml</location>
</error-page>

the stale-postback request ended in server-error.xhtml on both implementations, and the specific page was never used. The same mappings with <error-code>500</error-code> in place of the Throwable mapping behaved as expected (the table above). Use the status code 500 as the catch-all next to specific exception types. The OmniFaces documentation gives the same advice. (The mechanism is Tomcat’s and was not run on other containers: check yours.)

An Error Page That Fails

An error page is code like any other and can throw. If the exception handler redirects every failure to the error page, an error page that fails produces a redirect loop: with the page replaced by one that read the failing getter, both implementations redirected until the client gave up (curl stopped after the six redirects it was allowed). Two defences are in the example handler of the next section: it does not redirect again when the request is already for something under /error/, and it redirects to a static page then, a plain HTML file that cannot fail:

error/fallback.html
<!DOCTYPE html><html><head><title>Error</title></head><body><h1>Something went wrong</h1><p>Please try again later.</p></body></html>

Keep error views simple: no database access, no beans with side effects, no dependency on the state of the failed request, and no navigation that needs a session. A container-forwarded error page (without the handler) has the same needs.

ExceptionHandlerFactory and ExceptionHandlerWrapper

Taking control means installing your own ExceptionHandler. Faces uses the decorator pattern for this, as for most of its pluggable services: a factory creates the handler for each request, and your factory wraps the factory that was installed before it, so that your handler can wrap the previous handler and call it for everything it does not want to deal with.

The API

  • jakarta.faces.context.ExceptionHandlerFactory is an abstract class, FacesWrapper<ExceptionHandlerFactory>. You extend it, call super(wrapped) from a constructor that takes the previous factory (the no-argument constructor is deprecated in the 4.1 API), and implement getExceptionHandler(). getWrapped() returns the previous factory. The runtime obtains the factory with FactoryFinder.getFactory(FactoryFinder.EXCEPTION_HANDLER_FACTORY), and the FacesContextFactory gives each new FacesContext a fresh handler from it. Handler instances are therefore request scoped.

  • jakarta.faces.context.ExceptionHandler is the abstract handler: handle(), getUnhandledExceptionQueuedEvents(), getHandledExceptionQueuedEvent(), getHandledExceptionQueuedEvents(), getRootCause(Throwable), processEvent(SystemEvent) and isListenerForSource(Object). (The specification text uses the shorter names getHandledExceptionEvent, getHandledExceptionEvents and getUnhandledExceptionEvents; the 4.1 API documentation is what compiles.)

  • jakarta.faces.context.ExceptionHandlerWrapper implements FacesWrapper<ExceptionHandler> and calls through to the wrapped handler for every method. You override only handle(). The constructor takes the wrapped handler; the no-argument one is deprecated.

  • jakarta.faces.event.ExceptionQueuedEvent carries an ExceptionQueuedEventContext, available from getContext().

  • FacesContext.getExceptionHandler() and setExceptionHandler() expose the handler of the current request; a handler can be replaced for one request, but registering a factory is the way to install it for all.

The registration is in faces-config.xml, in the <factory> element:

faces-config.xml
<?xml version="1.0" encoding="UTF-8"?>
<faces-config xmlns="https://jakarta.ee/xml/ns/jakartaee"
              xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
              xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-facesconfig_4_1.xsd"
              version="4.1">
  <factory>
    <exception-handler-factory>org.example.bookshelf.web.BookshelfExceptionHandlerFactory</exception-handler-factory>
  </factory>
</faces-config>

There is no annotation for factories in 4.1. With several <exception-handler-factory> entries in one file, each wraps the one before it, and the one listed last is the outermost: its handler’s handle() runs first. The test: with the OmniFaces factory listed before the Bookshelf factory, an Ajax failure ended in the Bookshelf redirect and the OmniFaces handler never saw the event; with the order reversed, the OmniFaces error page replaced the document. The order of several configuration files is covered in the configuration page.

What an Event Tells You

ExceptionQueuedEventContext is the context of the event:

Member Meaning

getException()

The throwable that was queued. It is usually wrapped (Unwrapping).

getComponent()

The component being processed, or null. In the run, MyFaces reported lab:runtime for an action exception and Mojarra reported null.

getPhaseId()

The phase being processed. The toString differs: INVOKE_APPLICATION 5 on Mojarra, INVOKE_APPLICATION(5) on MyFaces.

getContext()

The FacesContext.

getAttributes()

A map for your own data. Use publishEvent with extra attributes when you queue by hand.

inBeforePhase(), inAfterPhase(), IN_BEFORE_PHASE_KEY, IN_AFTER_PHASE_KEY

Tell that the exception came from the beforePhase or afterPhase part of the phase.

Unwrapping

ExceptionHandler.getRootCause(Throwable) unwraps "until the unwrapping encounters an Object whose getClass() is not equal to FacesException.class or jakarta.el.ELException.class`". That is exact class equality, so a subclass of `ELException (MyFaces’s ContextAwareELException) is a root cause for it, and a handler that relies on the method sees the wrapper and not the exception. Write the loop yourself and test with instanceof, as the example does. On Mojarra the thrown type was FacesException for an action and ELException for a getter in a template; both were unwrapped correctly.

A Complete Handler

The handler of the Bookshelf does the following for each queued event, in order:

  1. Takes the event out of the queue (Iterator.remove()), so that no other handler also acts on it.

  2. Unwraps it to the real cause.

  3. Ignores AbortProcessingException (logs it at FINE).

  4. For a ViewExpiredException, remembers a redirect to the same view with ?expired=true.

  5. For anything else, generates a reference, logs the exception with that reference, the phase and the component, and remembers a redirect to the error view with the reference as the only parameter, or to the static fallback page when the request already came from /error/.

  6. After the loop, redirects once (the first event decides; later events are logged at WARNING), completes the response, and calls the wrapped handler for whatever remains.

It does nothing when the response is already committed (Rendering Failures and the Buffer): there is no recovery to attempt.

org.example.bookshelf.web.BookshelfExceptionHandlerFactory
package org.example.bookshelf.web;

import jakarta.faces.context.ExceptionHandler;
import jakarta.faces.context.ExceptionHandlerFactory;

public class BookshelfExceptionHandlerFactory extends ExceptionHandlerFactory {

    public BookshelfExceptionHandlerFactory(ExceptionHandlerFactory wrapped) {
        super(wrapped);
    }

    @Override
    public ExceptionHandler getExceptionHandler() {
        return new BookshelfExceptionHandler(getWrapped().getExceptionHandler());
    }
}
org.example.bookshelf.web.BookshelfExceptionHandler
package org.example.bookshelf.web;

import jakarta.el.ELException;
import jakarta.faces.FacesException;
import jakarta.faces.application.ViewExpiredException;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.ExceptionHandler;
import jakarta.faces.context.ExceptionHandlerWrapper;
import jakarta.faces.context.ExternalContext;
import jakarta.faces.context.FacesContext;
import jakarta.faces.event.AbortProcessingException;
import jakarta.faces.event.ExceptionQueuedEvent;
import jakarta.faces.event.ExceptionQueuedEventContext;
import java.io.IOException;
import java.util.Iterator;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;

public class BookshelfExceptionHandler extends ExceptionHandlerWrapper {

    private static final Logger LOG = Logger.getLogger(BookshelfExceptionHandler.class.getName());

    public BookshelfExceptionHandler(ExceptionHandler wrapped) {
        super(wrapped);
    }

    @Override
    public void handle() {
        FacesContext faces = FacesContext.getCurrentInstance();
        ExternalContext external = faces.getExternalContext();
        if (external.isResponseCommitted()) {
            getWrapped().handle();
            return;
        }
        Iterator<ExceptionQueuedEvent> events = getUnhandledExceptionQueuedEvents().iterator();
        String target = null;
        while (events.hasNext()) {
            ExceptionQueuedEventContext context = events.next().getContext();
            Throwable cause = rootCause(context.getException());
            events.remove();
            if (cause instanceof AbortProcessingException) {
                LOG.log(Level.FINE, "Processing aborted: {0}", cause.getMessage());
            } else if (target != null) {
                LOG.log(Level.WARNING, "Further exception in the same request", cause);
            } else if (cause instanceof ViewExpiredException expired) {
                LOG.log(Level.INFO, "View expired: {0}", expired.getViewId());
                target = expired.getViewId() == null
                        ? external.getRequestContextPath() + "/"
                        : faces.getApplication().getViewHandler().getActionURL(faces, expired.getViewId())
                                + "?expired=true";
            } else {
                String reference = UUID.randomUUID().toString().substring(0, 8);
                UIComponent component = context.getComponent();
                LOG.log(Level.SEVERE, "Unhandled exception, reference " + reference + ", phase "
                        + context.getPhaseId() + ", component "
                        + (component == null ? "none" : component.getClientId(faces)), cause);
                String errorPage = external.getRequestServletPath().startsWith("/error/")
                        ? "/error/fallback.html"
                        : "/error/server-error.xhtml?ref=" + reference;
                target = external.getRequestContextPath() + errorPage;
            }
        }
        if (target != null) {
            try {
                external.redirect(target);
            } catch (IOException e) {
                throw new FacesException(e);
            }
            faces.responseComplete();
        }
        getWrapped().handle();
    }

    private static Throwable rootCause(Throwable thrown) {
        Throwable current = thrown;
        while ((current instanceof FacesException || current instanceof ELException) && current.getCause() != null) {
            current = current.getCause();
        }
        return current;
    }
}

Results with the handler and the error-page entries of the earlier section, Production, on both implementations:

Request Plain (non-Ajax) Ajax

runtime, missingBook, queueAndContinue, or a failing getter in render-fail.xhtml

HTTP 302 to /bookshelf/error/server-error.xhtml?ref=<8 hex digits>; the error page shows the reference; the log has the full stack trace under the same reference

HTTP 200, <partial-response><redirect url="/bookshelf/error/server-error.xhtml?ref=..."></redirect></partial-response>

A postback with the view state of another session

HTTP 302 to /bookshelf/lab.xhtml?expired=true

HTTP 200 with a redirect element to the same URL

abort, abortl

HTTP 200, the page redisplayed, nothing logged above FINE

HTTP 200, a normal update

In headless Chrome the Ajax cases behaved as a user would expect: the browser followed the redirect element and showed the error page (with the reference) or the original page with the note "Your session expired". The reference in the log and on the page matched, with this log line (Mojarra; MyFaces prints INVOKE_APPLICATION(5) and lab:runtime):

SEVERE: Unhandled exception, reference dd313710, phase INVOKE_APPLICATION 5, component none
java.lang.IllegalStateException: The review service is down
	at org.example.bookshelf.web.LabBean.runtime(LabBean.java:25)

The redirect goes through ExternalContext.redirect(), which is Ajax-aware (it writes the redirect element of the partial response), so one code path serves both kinds of request.

The requests behind the table can be reproduced with curl. The first command fetches the page and keeps the session cookie, the second posts the Runtime button like a browser would (both lab=lab and lab_SUBMIT=1 are sent because Mojarra and MyFaces each look at one of them), the third sends the same button as an Ajax request:

Reproducing a plain and an Ajax failure
URL=http://localhost:8080/bookshelf/lab.xhtml
curl -s -c jar -b jar "$URL" -o page.html
VS=$(grep -o 'name="jakarta.faces.ViewState"[^>]*value="[^"]*"' page.html | sed 's/.*value="//;s/"//')

curl -s -i -c jar -b jar "$URL" \
  --data-urlencode "lab=lab" --data-urlencode "lab_SUBMIT=1" --data-urlencode "lab:runtime=Runtime" \
  --data-urlencode "jakarta.faces.ViewState=$VS"

curl -s -i -c jar -b jar "$URL" -H 'Faces-Request: partial/ajax' \
  --data-urlencode "jakarta.faces.partial.ajax=true" --data-urlencode "jakarta.faces.source=lab:aruntime" \
  --data-urlencode "jakarta.faces.partial.execute=lab" --data-urlencode "jakarta.faces.partial.render=lab" \
  --data-urlencode "jakarta.faces.behavior.event=action" --data-urlencode "jakarta.faces.partial.event=click" \
  --data-urlencode "lab=lab" --data-urlencode "lab_SUBMIT=1" --data-urlencode "lab:aruntime=" \
  --data-urlencode "jakarta.faces.ViewState=$VS"

Queueing an Exception by Hand

Catching an exception inside an action and passing it to the central policy while the action carries on is a legitimate use of the event:

The queueAndContinue method of LabBean
    public void queueAndContinue() {
        try {
            books.require(99);
        } catch (BookNotFoundException e) {
            faces.getApplication().publishEvent(faces, ExceptionQueuedEvent.class,
                    new ExceptionQueuedEventContext(faces, e));
        }
        faces.addMessage(null, new FacesMessage("The action went on after queueing the exception"));
    }

The action completed (the message was added), and at the end of the phase the handler redirected to the error page like for a thrown exception. The ExceptionHandler class documentation shows the same pattern with the call publishEvent(ExceptionQueuedEvent.class, eventContext); that two-argument form does not exist in the 4.1 Application API, which has publishEvent(FacesContext, Class, Object) and a four-argument variant, so copy the three-argument call above.

Rendering Failures and the Buffer

A redirect needs an uncommitted response. Facelets writes the page into a buffer and flushes it as the buffer fills, so the first bytes of a long page may already be on the wire when a getter fails near the end. The context parameter jakarta.faces.FACELETS_BUFFER_SIZE (documented with a default of 1024 bytes by Mojarra, whose description adds that a larger buffer guarantees, during development, that an error cannot arrive after the response was partially flushed) decides how much can be retracted. With the default, a page that wrote 3 KB of text before it failed could still be redirected on both implementations (the servlet container’s own response buffer also counts); a page that wrote 20 KB and then failed was already committed:

Buffer Mojarra 4.1.17 MyFaces 4.1.4

Default

HTTP 200, 20,440 bytes: a truncated page and no redirect

HTTP 200, 20,414 bytes: a truncated page and no redirect

FACELETS_BUFFER_SIZE 65536

HTTP 302 to the error page

HTTP 302 to the error page

With 200 KB of text and the 64 KB buffer, the response was committed again: Mojarra logged Skipping attempt to set buffer size on a committed response and appended the container’s mapped error page to the truncated content; MyFaces just truncated it. A response whose status is 200 and whose content stops in the middle cannot be repaired by any handler; it can only be avoided by keeping the part that is rendered before a possible failure small, by loading the data before rendering (in a viewAction, or in the action of the previous request) so that a failure happens while the response is still empty, or by raising the buffer to the size of your largest page and accepting the memory per request. The handler above, given a committed response, passes the event to the wrapped handler so that the default behaviour (container page, truncated response) at least applies, and the log still has the exception. The PrimeFaces documentation says the same about FACELETS_BUFFER_SIZE, calling it a limitation of every handler.

Handler Mistakes

  • Swallowing. A handler that removes the events and does nothing else turns every failure into a normal page. The test handler SilentExceptionHandler did that: the action threw, HTTP 200 came back with the form redisplayed, and nothing was logged at all (the log held no trace of the IllegalStateException). Always log, and always give the user a visible outcome.

  • Handling only the first event. A request can queue several events. The example ignores later ones after logging them.

  • Not calling the wrapped handler. The previous handler (another library’s, or the implementation’s) never runs.

  • Calling the navigation handler in Render Response. Prohibited since 2.3 (Where It Sits in the Lifecycle).

  • Redirect loops and committed responses. See above.

  • Relying on getRootCause(). Use your own loop with instanceof.

  • State in the handler. The instance is per request, but the factory is application wide: keep no request data in the factory.

ViewExpiredException

jakarta.faces.application.ViewExpiredException is a FacesException (since Faces 1.2) that the Restore View phase throws on a postback when the view state cannot be turned back into a component tree: the specification requires it to signal that the view expected for the view identifier was not returned, and says that an application may react to it. getViewId() returns the view id, or null when it is unknown. It is the most common production exception of a Faces application and not a bug in it.

Causes seen in the earlier pages and runs:

  • The session ended (timeout, logout in another tab, invalidateSession(), a server restart or redeploy) while the page was open. The run reproduced it with a form posted with the view state of another session.

  • The server dropped the saved state of an old view to make room for newer ones: an evicted view gives the exception on the next postback on both implementations (view scope limits).

  • The client-side saved state is older than com.sun.faces.clientStateTimeout (Mojarra; it is active only when set).

The messages differ: Mojarra viewId:/lab.xhtml - View /lab.xhtml could not be restored. and MyFaces View "/lab.xhtml" could not be restored. (HTTP 500 in both when it is unhandled). The user’s input of the failed request is lost on every option below; the point is to say so.

Table 1. What to do about it
Option What happens Verified

<error-page> for the exception type

The container forwards a plain request to a page you write. Works for non-Ajax requests; an Ajax request needs the options below.

Both implementations; not a catch-all Throwable (java.lang.Throwable Hides the More Specific Mappings)

A handler that redirects to the same view (the example above)

ExternalContext.redirect() to the view’s own URL with ?expired=true; the page shows a note. One path for Ajax and non-Ajax requests.

Both implementations, in curl and in Chrome (plain and Ajax)

OmniFaces ViewExpiredExceptionHandler

Suppresses the exception and redirects to the current URL with its query string, and sets the flash attribute org.omnifaces.view_expired.

Both implementations, in Chrome (plain and Ajax); the page read #{flash['org.omnifaces.view_expired'] eq true}

OmniFaces FullAjaxExceptionHandler

Renders your ViewExpiredException error page for plain and Ajax requests alike (OmniFaces FullAjaxExceptionHandler).

Both implementations, in Chrome

A script on the client (faces.ajax.addOnError)

Reloads the page when errorName contains ViewExpiredException, for applications that keep the default server behaviour (An Error Policy on the Client).

Both implementations, in Chrome

The handler of the example computes the URL with ViewHandler.getActionURL() and the view id, which drops query parameters. A page whose address carries view parameters should redirect to the original request URL, with its query string, instead; the form’s postback address has no query string (view parameters on a postback). The OmniFaces handler keeps the query string of the failing request.

Prevention is the other half of the policy. Choose a session timeout that matches how long a user keeps a form open, size the views-per-session setting for the number of tabs users really use (the state-saving page covers server and client state saving and the parameters), consider stateless views for pages that only display data, and warn the user before a long-lived form expires. A page that expired while the user was typing is still the user’s loss, so the note on the next page matters.

Ajax Exceptions

The Ajax side of the model is in the Ajax page: the default handler writes <partial-response><error><error-name>...</error-name><error-message>...</error-message></error></partial-response> (the specification’s format, with the class as the name and the cause, or else the message, as the message), the HTTP status is 200, and faces.js turns the element into an event with status serverError for onerror and addOnError. What that page established, and what this page needs:

  • Mojarra’s Production message is the fixed text See your server log for more information; MyFaces’s is the full exception text (with the internal JDBC URL of the example) in every stage.

  • Mojarra names the exception class (prefixed with class `); MyFaces names `ContextAwareELException, unless WRAP_TAG_EXCEPTIONS_AS_CONTEXT_AWARE is false, when it names the real class like Mojarra without the prefix.

  • An expired view arrives as an error element too, and the default server behaviour is to leave the user on a page that does nothing.

  • No error page is involved: the Servlet <error-page> mappings do not apply to an Ajax response.

So an Ajax failure needs one of two answers: the server decides (a handler that redirects or renders an error page for Ajax requests, ExceptionHandlerFactory and ExceptionHandlerWrapper and OmniFaces FullAjaxExceptionHandler), or the client decides (an onerror policy, below). They can be combined; the server answer is what protects the details, because the default error element can carry them.

An Error Policy on the Client

A site-wide script registered with faces.ajax.addOnError gives every f:ajax the same reaction without touching the pages. The script below follows the lessons of the Ajax page: it matches errorName with includes, never shows the server’s message, logs the details to the console only, and reloads on an expired view.

resources/js/bookshelf-errors.js
/* Ajax error policy: one fixed message for the user, details only in the console. */
(function () {
    'use strict';

    faces.ajax.addOnError(function (data) {
        const name = String(data.errorName || '');
        if (name.includes('ViewExpiredException')) {
            window.location.reload();
            return;
        }
        console.error('Ajax error', data.status, name, 'HTTP', data.responseCode);
        const box = document.getElementById('ajax-error');
        if (box) {
            box.textContent = 'The request failed. Please try again.';
            box.hidden = false;
        }
    });
}());
lab.xhtml of the client policy (faces.js listed first)
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<h:head>
  <title>Lab</title>
  <h:outputScript library="jakarta.faces" name="faces.js" target="head"/>
  <h:outputScript library="js" name="bookshelf-errors.js" target="head"/>
</h:head>
<h:body>
  <p id="ajax-error" role="alert" hidden="hidden"></p>
  <h:messages id="messages" globalOnly="true"/>
  <h:form id="lab">
    <h:commandButton id="aruntime" value="ARuntime" action="#{labBean.runtime}"><f:ajax execute="@form" render="@form :messages"/></h:commandButton>
    <h:commandButton id="aok" value="AOk" action="#{labBean.ok}"><f:ajax execute="@form" render="@form :messages"/></h:commandButton>
  </h:form>
</h:body>
</html>

In Chrome, on both implementations, the failing Ajax button showed The request failed. Please try again., and the Ajax button of a page whose session cookie had been deleted reloaded the page (a marker variable set on the old document was gone). Neither the exception class nor the message reached the page. The script does not replace the server side: it does not stop the detail from being sent, and a script can be bypassed.

OmniFaces FullAjaxExceptionHandler

OmniFaces (5.5.5) makes an Ajax request fail exactly like a plain one: the same error page, chosen the same way. It is a ExceptionHandlerWrapper with a factory, installed in faces-config.xml:

faces-config.xml
<?xml version="1.0" encoding="UTF-8"?>
<faces-config xmlns="https://jakarta.ee/xml/ns/jakartaee"
              xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
              xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-facesconfig_4_1.xsd"
              version="4.1">
  <factory>
    <exception-handler-factory>org.omnifaces.exceptionhandler.FullAjaxExceptionHandlerFactory</exception-handler-factory>
  </factory>
</faces-config>
web.xml (an error page for 500 is required)
  <error-page>
    <exception-type>jakarta.faces.application.ViewExpiredException</exception-type>
    <location>/error/expired.xhtml</location>
  </error-page>
  <error-page>
    <error-code>404</error-code>
    <location>/error/not-found.xhtml</location>
  </error-page>
  <error-page>
    <error-code>500</error-code>
    <location>/error/server-error.xhtml</location>
  </error-page>
error/server-error.xhtml for OmniFaces (the page may be public or under WEB-INF)
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html">
<h:head><title>Something went wrong</title></h:head>
<h:body>
  <h1>Something went wrong</h1>
  <p>The error has been recorded. If you contact support, quote the reference
     <strong><h:outputText value="#{requestScope['org.omnifaces.exception_uuid']}"/></strong>.</p>
  <p><a href="#{request.contextPath}/lab.xhtml">Back to the catalogue</a></p>
</h:body>
</html>

The documentation of the class sets the rules, and the run confirmed them:

  • The error pages are read from web.xml and web-fragment.xml. The location of status 500 or of java.lang.Throwable is required; without one, the run logged IllegalArgumentException: Either HTTP 500 or java.lang.Throwable error page is required in web.xml or web-fragment.xml and the container’s own report was served. The Throwable page takes precedence over all others; with specific exception types, use 500 as the fallback (the same advice as java.lang.Throwable Hides the More Specific Mappings).

  • Locations must be Facelets views mapped by the FacesServlet (JSP is not supported), and can be below /WEB-INF (the Javadoc example uses /WEB-INF/errorpages/), because the page is rendered in the same request and not redirected to; the scratch run used /error/.

  • The page is rendered into the Ajax response. The partial response replaced jakarta.faces.ViewRoot with the error page, with HTTP 200, and the browser showed it (headless Chrome, both implementations). The user never sees a blank page for an Ajax failure.

  • Exception details are request attributes of the usual Servlet names, plus org.omnifaces.exception_uuid, a UUID that the handler also writes to the log line ([uuid][ip] FullAjaxExceptionHandler: ...). The page above shows the UUID and nothing else. The Javadoc example also prints the message and the stack trace; do not, in Production.

  • Plain requests need the FacesExceptionFilter. The handler does not touch plain requests. Since OmniFaces 4.5 it registers the filter on /* by itself when it is absent; the run logged FullAjaxExceptionHandler: the FacesExceptionFilter has been automatically installed at URL pattern of /*. The filter unwraps FacesException and ELException before the container matches an error page, which fixed the MyFaces problem of Which error-page Matches: with an exception-type mapping for BookNotFoundException, MyFaces rendered not-found.xhtml for both a plain and an Ajax request.

  • Context parameters. org.omnifaces.EXCEPTION_TYPES_TO_UNWRAP adds exception types to unwrap (for example jakarta.ejb.EJBException), and org.omnifaces.EXCEPTION_TYPES_TO_IGNORE_IN_LOGGING suppresses OmniFaces’s own log record for the listed types (standard Faces and container logging is not affected).

  • Rendering failures need jakarta.faces.FACELETS_BUFFER_SIZE to be large enough, like any handler (Rendering Failures and the Buffer). If the error page itself fails, the handler shows a hardcoded plain-text page and logs it (this was not run).

  • Parameters in the error page’s location (.../custom.xhtml?reason=x) are available as #{param} only with the MutableRequestFilter installed (since OmniFaces 5.4); without it they are dropped and a warning is logged.

  • Subclassing. findExceptionRootCause, shouldHandleExceptionRootCause, findErrorPageLocation and logException are protected hooks for your own policy; a factory for the subclass is then registered instead.

Differences observed between the implementations:

  • The Ajax response had HTTP 200 on both, with a different partial-response shape (MyFaces adds id="j_id__v_0" and a doctype).

  • Mojarra replaced the body and left the document title unchanged (Lab); MyFaces replaced the whole document, title included.

  • An error page that sets its own status (the markAsServerError listener of the redirect-based page above) made Mojarra answer the Ajax request with HTTP 500, and the browser was left with an empty page. MyFaces answered 200 and showed the error page. The OmniFaces page above does not set the status; the container sets 500 for plain requests.

OmniFaces registers further handlers with the same shape:

  • ViewExpiredExceptionHandler (ViewExpiredException). Register it last when combined with the full Ajax handler, as the documentation says, or the full handler takes over the expired views.

  • FacesMessageExceptionHandler adds every exception as a global FATAL FacesMessage; the application is responsible for showing it (h:messages, updated by Ajax), and it cannot handle exceptions in Render Response. It was not run.

  • ExceptionSuppressor suppresses the exception types listed in org.omnifaces.EXCEPTION_TYPES_TO_SUPPRESS and refreshes the page (for network failures that nobody can act on); it was not run.

PrimeFaces Ajax Error Handling

PrimeFaces 16 has its own error-handling chain. The chain is described here from the PrimeFaces documentation; what happened when it was run is in the PrimeFaces Ajax forms page and is summarised below. On the client, an error goes through the onerror callbacks of p:ajax, the global listeners such as p:ajaxStatus and the jQuery pfAjaxError event, then through a matching p:ajaxExceptionHandler, then through a redirect to the matching error-page, and finally to the browser console. It also hooks the standard f:ajax errors, so faces.ajax.addOnError listeners keep working.

p:ajaxExceptionHandler declares the reaction on the page, per exception type, with a callback and components to update, which suits errors that should not leave the page:

<p:ajaxExceptionHandler type="jakarta.faces.application.ViewExpiredException"
                        onexception="location.reload();"/>

On the server, org.primefaces.application.exceptionhandler.PrimeExceptionHandlerFactory (with the PrimeExceptionHandlerELResolver, both registered in faces-config.xml) redirects to the error page for plain and for Ajax requests, so the Servlet error attributes are not available on the error page and the exception is exposed as the EL keyword pfExceptionHandler (exception, type, message, stackTrace, formattedStackTrace, timestamp, formattedTimestamp). It can skip logging of listed types (primefaces.EXCEPTION_TYPES_TO_IGNORE_IN_LOGGING on Mojarra; MyFaces has the equivalent built in) and, by the documentation, logs everything in all project stages except ViewExpiredException in Production. Its rendering limit is the same as every handler’s: set jakarta.faces.FACELETS_BUFFER_SIZE to support Render Response exceptions.

The PrimeFaces documentation shows an error page that prints pfExceptionHandler.message and pfExceptionHandler.formattedStackTrace with escape="false". That is a development convenience. In Production the page must not show either (the message is exception text and the stack trace is a map of the application), and escape="false" on text that derives from an exception message or a request turns a hostile input into script in the error page. Show a fixed text and a reference.

What the run showed (PrimeFaces 16.0.0, Mojarra 4.1.17 and MyFaces 4.1.4, Production stage, an action that throws an IllegalStateException): without PrimeExceptionHandlerFactory, a p:ajaxExceptionHandler type="java.lang.IllegalStateException" matched on Mojarra but did not match on MyFaces, whose Ajax error-name was ContextAwareELException (and whose error-message carried the whole exception text). With the factory and the PrimeExceptionHandlerELResolver registered in faces-config.xml, both implementations answered HTTP 200 with a normal partial response (an update plus a script) and the type matched on both. pfExceptionHandler.message contained the exception message, including a JDBC URL, so it is as sensitive as any exception text. Register the factory whenever p:ajaxExceptionHandler has to behave the same on both implementations; the registration and the full listings are on the PrimeFaces page.

Exceptions From Phase Listeners

An exception thrown by a PhaseListener method is, per the specification, published to the ExceptionHandler (with the before and after markers of What an Event Tells You). A listener registered on a view root is a special case: the context parameter jakarta.faces.VIEWROOT_PHASE_LISTENER_QUEUES_EXCEPTIONS (since Faces 2.3) says that, when true, exceptions thrown by phase listeners installed on the UIViewRoot are queued to the ExceptionHandler instead of being logged and swallowed. A listener that fails in the afterPhase of PROCESS_VALIDATIONS, registered with f:phaseListener, and a method bound to the afterPhase attribute of f:view, gave:

FailingPhaseListener
package org.example.bookshelf.web;

import jakarta.faces.event.PhaseEvent;
import jakarta.faces.event.PhaseId;
import jakarta.faces.event.PhaseListener;

public class FailingPhaseListener implements PhaseListener {

    @Override
    public void afterPhase(PhaseEvent event) {
        throw new IllegalStateException("view phase listener failure");
    }

    @Override
    public void beforePhase(PhaseEvent event) {
    }

    @Override
    public PhaseId getPhaseId() {
        return PhaseId.PROCESS_VALIDATIONS;
    }
}
viewroot2.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<f:view>
<f:phaseListener type="org.example.bookshelf.web.FailingPhaseListener"/>
<h:head><title>View root</title></h:head>
<h:body>
  <h:messages id="messages" globalOnly="true"/>
  <h:form id="vr">
    <h:commandButton id="ok" value="Ok" action="#{labBean.ok}"/>
  </h:form>
</h:body>
</f:view>
</html>
Source of the failure Mojarra, parameter unset Mojarra, true MyFaces, unset MyFaces, true

f:phaseListener type="..."

logged, swallowed (HTTP 200)

queued, reached the handler

queued, reached the handler

queued

afterPhase="#{bean.method}" on f:view

queued, reached the handler

queued

logged (An Exception occurred while processing ...), swallowed (HTTP 200)

logged, swallowed

The context parameter works as documented on Mojarra for f:phaseListener; MyFaces queued such exceptions with the parameter unset, and the afterPhase attribute is outside the parameter on both (queued on Mojarra, swallowed by MyFaces). A listener’s failure may therefore reach the handler or only the log, depending on the implementation: a handler cannot assume that every listener failure arrives, and a listener that must not fail silently has to log or queue the exception itself.

Logging

An exception that the user does not see in detail must be fully available to the operator. The policy of the example is the usual one:

  1. One log record per failure, at SEVERE, with the stack trace, from the code that handles it.

  2. A reference in the record and on the error page, so a support ticket that quotes the reference leads straight to the record.

  3. The phase and the component (client id) when the implementation provides them, and the unwrapped root cause.

  4. Expected conditions at a lower level: an expired view at INFO and an aborted request at FINE.

  5. Nothing sensitive: no request parameters (the Development page lists them for a reason), no headers or cookies, no passwords or tokens, no whole objects whose toString() is unknown. An exception message can itself contain sensitive data, which is one more reason to keep the log files protected and out of any user-facing channel.

  6. User-controlled text (the view id, URL, parameter values) goes into the message as a parameter of the logging framework, and line breaks in it should be neutralised if the log is read by people or by tools that trust line breaks (log forging).

Both implementations log through java.util.logging, and so does the example, which keeps it independent of any other logging library; a bridge (for example from JUL to SLF4J) routes it to the framework of the application. What the implementations log on their own, without a custom handler, in the runs:

Case Mojarra 4.1.17 MyFaces 4.1.4

Plain request, exception in an action

Tomcat’s Servlet.service() for servlet [FacesServlet] ... threw exception [...] with root cause plus the stack trace (SEVERE)

SEVERE: java.lang.IllegalStateException occurred while processing INVOKE_APPLICATION [Location=clientId="lab:runtime"], then the same Tomcat record

Ajax request, exception in an action

SEVERE: and the stack trace of the exception

SEVERE: java.lang.IllegalStateException occurred while processing INVOKE_APPLICATION [Location=clientId="lab:aruntime"]

Expired view (plain / Ajax)

the Tomcat record for plain requests; SEVERE with the stack trace for Ajax requests

SEVERE: jakarta.faces.application.ViewExpiredException occurred while processing RESTORE_VIEW for both kinds of request, plus the Tomcat record for plain requests

An expired view logged at SEVERE with a stack trace is noise. MyFaces’s org.apache.myfaces.EXCEPTION_TYPES_TO_IGNORE_IN_LOGGING removed the ViewExpiredException record of an Ajax request in the run; the Tomcat record of a plain request stayed, since the container logs it. Mojarra documents com.sun.faces.exceptionTypesToIgnoreInLogging (since 4.0.14) for its default handler; in the runs of 4.1.17 the expired-view record of the Ajax request and the Tomcat record of the plain request were written with the parameter set (its effect was not observed), so a custom handler that logs only what it chooses is the reliable way to control the noise.

Expected Failures Are Not Exceptions for the Handler

The central handler is for the unexpected. A lookup for a book that does not exist is an everyday event, and the right reaction is a message beside the field, not an error page. The action catches the exception, adds a FacesMessage with text written for the user (never e.getMessage(), which carries internal detail in this example) and stays on the page:

org.example.bookshelf.web.BookLookupBean
package org.example.bookshelf.web;

import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.example.bookshelf.model.Book;
import org.example.bookshelf.model.BookNotFoundException;
import org.example.bookshelf.service.BookService;

@Named
@RequestScoped
public class BookLookupBean {

    @Inject
    private BookService books;
    @Inject
    private FacesContext faces;

    private Long id;
    private Book book;

    public void lookup() {
        try {
            book = books.require(id);
        } catch (BookNotFoundException e) {
            faces.addMessage("lookup:id", new FacesMessage(FacesMessage.SEVERITY_WARN,
                    "There is no book with that number.", null));
        }
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Book getBook() {
        return book;
    }
}
lookup.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<h:head><title>Look up a book</title></h:head>
<h:body>
  <h:form id="lookup">
    <h:outputLabel for="id" value="Book number"/>
    <h:inputText id="id" value="#{bookLookupBean.id}" required="true"/>
    <h:commandButton id="find" value="Find" action="#{bookLookupBean.lookup}">
      <f:ajax execute="@form" render="@form"/>
    </h:commandButton>
    <h:message id="idMessage" for="id"/>
    <h:outputText id="title" value="#{bookLookupBean.book.title}" rendered="#{bookLookupBean.book ne null}"/>
  </h:form>
</h:body>
</html>

In Chrome, on both implementations, number 99 showed There is no book with that number. beside the form and number 3 showed Neuromancer; the handler was not involved. Messages are covered in the messages page, the checks that make a failure ordinary in the converters and validators pages, and the status for a missing resource behind a bookmarkable URL (a real 404) in view parameters and view actions.

Production Hardening Checklist

Error handling is a security feature. A response to a failed request is information, and an attacker provokes failures on purpose. This checklist is the result of the cases above.

  • The project stage is Production. The Development pages print stack traces, the component tree and the request parameters.

  • No raw container report. Map 404 and 500 (and the other status codes the application can produce, such as 403) in web.xml, and on Tomcat set showReport="false" and showServerInfo="false" on the ErrorReportValve of the host as well. The unmapped Production failure printed the message and the stack frames with the application’s class names.

  • No exception text in Ajax responses. Mojarra replaces the message in Production; MyFaces does not. Install a handler (this page’s redirect or the OmniFaces handler) or a response policy that does not send the text, and add a client onerror policy for the rest.

  • The error page says little. A fixed sentence and a reference. No jakarta.servlet.error.* values, no pfExceptionHandler message or stack trace, no request parameters, and no escape="false" on anything that derives from an exception or a request.

  • The reference is not a secret and not a key. It is an index into the log; it grants nothing and says nothing.

  • Redirect targets are fixed. The handler builds its target from constants and the application’s own view id, never from a request parameter, so it cannot be turned into an open redirect.

  • Error pages are reachable. A redirect makes the browser request the page, so the redirect-based example’s pages live outside /WEB-INF/ and outside any security constraint that would send the user to a login page instead; container forwards and the OmniFaces handler may use /WEB-INF/.

  • Error pages cannot fail, and a static fallback exists (An Error Page That Fails).

  • The status is honest. A failure ends in a 5xx (or 4xx) status that monitoring can count: set it on the error page.

  • Logs are protected and contain no secrets or credentials; the exception messages in them can be sensitive.

  • The session id stays out of URLs. <tracking-mode>COOKIE</tracking-mode> in web.xml: with URL rewriting active, the address of the page after a postback carried ;jsessionid=... in the run (headless Chrome, a first request without a cookie).

  • Do not turn errors into authorisation. An exception is not a check; authorise in code and with the container’s constraints (the security page covers them).

The Tomcat hardening of the second item goes into server.xml (a valve of the Host), with the attributes of the configuration reference (not run in the scratch application, which embeds Tomcat):

server.xml (Tomcat)
<Host name="localhost" appBase="webapps">
  <Valve className="org.apache.catalina.valves.ErrorReportValve" showReport="false" showServerInfo="false"/>
</Host>

Choosing a Strategy

flowchart TD A[Start: a Production policy for failures] --> B{Plain pages only?} B -- yes --> C["error-page entries for 404, 500 and ViewExpiredException"] B -- no --> D{Use OmniFaces?} D -- yes --> E["FullAjaxExceptionHandler plus error pages; ViewExpiredExceptionHandler last"] D -- no --> F["Your ExceptionHandlerWrapper: log, redirect, handle expired views"] C --> G["Add a client onerror policy and the container hardening"] E --> G F --> G

The three routes differ in cost and reach. Error pages alone are standard and need nothing else, but cover Ajax only through the client. A custom handler is under 100 lines, is yours to read and test, and works with any library; it must be maintained and must avoid the mistakes above. OmniFaces covers the Ajax case and the unwrapping in a tested library and adds a dependency; PrimeFaces applications have an equivalent in their own handler (PrimeFaces Ajax Error Handling). All three keep the details in the log.

Mojarra and MyFaces

Same on both: handle() after every executed phase; the default handler rethrowing the first event for a plain request and writing an error element for Ajax; the default Production report coming from the container; ViewExpiredException for a stale state with HTTP 500; the redirects of the example handler (plain and Ajax, and in the browser); FACELETS_BUFFER_SIZE deciding whether a late failure is redirectable; the redirect loop of a failing error page; a Servlet <error-page> matching ViewExpiredException and the 404 code; the OmniFaces handlers working. Differences found in the scratch application:

  • Exception type seen by the container: the application’s class on Mojarra, ContextAwareELException on MyFaces (unless WRAP_TAG_EXCEPTIONS_AS_CONTEXT_AWARE is false, or the OmniFaces filter unwraps). getRootCause() does not unwrap it.

  • Production Ajax message: fixed text on Mojarra, the full exception text on MyFaces.

  • Ajax error-name: class <name> on Mojarra, the wrapper name on MyFaces; and on MyFaces an invalid view state gave IllegalArgumentException.

  • AbortProcessingException from an action method: reaches Mojarra’s default handler (HTTP 500), ignored by MyFaces’s.

  • ExceptionQueuedEventContext.getComponent() for an action exception: null on Mojarra, the command component on MyFaces.

  • PhaseId.toString(): INVOKE_APPLICATION 5 versus INVOKE_APPLICATION(5).

  • Development error page: both print it; MyFaces can switch it off (org.apache.myfaces.ERROR_HANDLING).

  • Logging parameters: org.apache.myfaces.EXCEPTION_TYPES_TO_IGNORE_IN_LOGGING worked; the Mojarra one had no visible effect.

  • Phase listener exceptions: the table of Exceptions From Phase Listeners.

  • OmniFaces Ajax error page: Mojarra keeps the document title and rejects a 500 status; MyFaces replaces the document and ignores the status.

  • A getter failure in a late part of a page: Mojarra appended the container’s mapped error page to a committed response, MyFaces left it truncated.

What Changed

Version Change

Faces 1.2

ViewExpiredException (the API documentation says Since: 1.2). The ExceptionHandler API did not exist yet (it says Since: 2.0).

Faces 2.0

ExceptionHandler, ExceptionHandlerFactory, ExceptionHandlerWrapper, ExceptionQueuedEvent and ExceptionQueuedEventContext (the API documentation says Since: 2.0), the Ajax error element in the partial response and the onerror hooks. The decorator pattern of the wrappers dates from here.

Faces 2.2

The change list says that AbortProcessingException is added to the exceptions that do not get to the ExceptionHandler (specification text only: the implementations disagree, Exceptions That Are Not Handled Here).

Faces 2.3

Issue 1401: the NavigationHandler is explicitly prohibited inside an ExceptionHandler invoked during Render Response, and advisory text was added to the ExceptionHandler. The parameter jakarta.faces.VIEWROOT_PHASE_LISTENER_QUEUES_EXCEPTIONS appeared (Mojarra’s parameter table says 2.3). The 4.1 API marks the no-argument constructors of the wrapper and the factory as deprecated (the release that deprecated them was not verified).

Faces 3.0 / 4.0

The package and parameter names changed from javax.faces to jakarta.faces. The specification change lists have no ExceptionHandler entry. Mojarra 4.0.14 added com.sun.faces.exceptionTypesToIgnoreInLogging.

Faces 4.1

No change to the exception classes. Full state saving is deprecated in 4.1 (see the state-saving page); that changes what a saved view is, not how an expired one is reported.

Faces 5.0

Under development (the latest milestone at the time of writing is jakarta.faces-api:5.0.0-M9); no exception-handling change is described here, because none was verified.

Where the Books Are Outdated

Four of the books of the section’s bibliography touch exception handling: Burns and Schalk list the ExceptionHandler, factories and the decorator pattern among the non-UI extension points, Leonard presents the ExceptionHandler as one of the Faces 2.0 features, Çalışkan and Varaksin close with PrimeFaces’s Ajax exception handling, and Jonna has a chapter on Ajax error handling for the PrimeFaces Extensions. Their code was written for Faces 2.0 to 2.2 and PrimeFaces 3 to 5; what that changes today:

  • Names. The javax.faces packages and javax.faces.* parameters of that era are jakarta.faces since Faces 3.0. A copied handler does not compile.

  • The wrapper constructors. The 2.0 idiom of a wrapper with a no-argument constructor is deprecated in the 4.1 API; pass the wrapped object to the constructor, as the example does.

  • Navigation from a handler. The advice to send the user to an error view with the navigation handler is invalid during Render Response since Faces 2.3 (issue 1401), which postdates the books. Redirect through the ExternalContext.

  • Per-implementation behaviour. Nothing in the 2.0-era descriptions mentions that MyFaces wraps tag exceptions, that getRootCause() does not unwrap them, or that AbortProcessingException is treated differently by the two implementations (Exceptions That Are Not Handled Here).

  • Library mechanisms. The PrimeFaces mechanisms of the PrimeFaces books are superseded by p:ajaxExceptionHandler, the PrimeFaces ExceptionHandler and the error chain of the current documentation (PrimeFaces Ajax Error Handling). Whether the Extensions component of Jonna’s chapter still exists was not verified; use the PrimeFaces core handler, or the standard faces.ajax.addOnError.

  • Production. The books treat the error page as a page that explains the failure. Today it does the opposite: a fixed message and a reference, with the explanation in the log (Production Hardening Checklist). OmniFaces’s FullAjaxExceptionHandler and the Production-stage hardening of this page do not appear in them.

Common Mistakes

  • The user sees a stack trace. The stage is Development, or no error-page is mapped and Tomcat’s report is shown.

  • The Ajax response has the exception text. On MyFaces it does in Production. Install a handler.

  • java.lang.Throwable next to specific mappings. On Tomcat it shadows them. Use 500.

  • An exception-type mapping that never matches on MyFaces. The exception arrives wrapped. Unwrap (OmniFaces) or set WRAP_TAG_EXCEPTIONS_AS_CONTEXT_AWARE to false.

  • A handler that logs nothing. Failures vanish and the user sees a normal page.

  • A redirect loop. The error page fails and the handler redirects to it again. Guard and use a static fallback.

  • NavigationHandler in a handler during Render Response. Prohibited; redirect through the ExternalContext.

  • Redirecting after the response is committed. It cannot work; check isResponseCommitted() and size the buffer.

  • AbortProcessingException becomes an error page. Unwrap and ignore it explicitly.

  • The 500 error page returns 200. A redirect-based error page has to set its status.

  • The error page sets 500 under OmniFaces. Mojarra’s Ajax client then rejects the response.

  • An expired view loses the user’s input silently. Tell the user, and keep forms short-lived or stateless.

  • Printing exception text with escape="false". Cross-site scripting waiting to happen.

  • getRootCause() relied upon. It does not unwrap ContextAwareELException.

  • A secret in the log. Exceptions and parameters carry data; log the reference and the cause only.