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.
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
GETof 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 |
HTTP 200, page redisplayed |
HTTP 200, page redisplayed |
The |
HTTP 500 (an Ajax request gets an |
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:
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;
}
}
public Book require(long id) {
return books.stream()
.filter(b -> b.id() == id)
.findFirst()
.orElseThrow(() -> new BookNotFoundException(id));
}
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));
}
}
<!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 |
|---|---|---|
|
HTTP 500 with Tomcat’s own error report: title |
The same Tomcat report, with the root-cause chain through |
|
|
|
|
HTTP 500 and a Faces debugging page titled |
The same kind of page (MyFaces: |
|
|
|
A failure while rendering ( |
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.
<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>
<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.
<!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>
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 : "";
}
}
<!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).
Source: Jakarta Faces 4.1 specification, 6.2 ExceptionHandler and Jakarta Faces 4.1 specification, 2.2 Request Processing Lifecycle
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 |
|
|
|
|
|
A |
|
|
|
|
|
An unknown |
|
|
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:
<!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.ExceptionHandlerFactoryis an abstract class,FacesWrapper<ExceptionHandlerFactory>. You extend it, callsuper(wrapped)from a constructor that takes the previous factory (the no-argument constructor is deprecated in the 4.1 API), and implementgetExceptionHandler().getWrapped()returns the previous factory. The runtime obtains the factory withFactoryFinder.getFactory(FactoryFinder.EXCEPTION_HANDLER_FACTORY), and theFacesContextFactorygives each newFacesContexta fresh handler from it. Handler instances are therefore request scoped. -
jakarta.faces.context.ExceptionHandleris the abstract handler:handle(),getUnhandledExceptionQueuedEvents(),getHandledExceptionQueuedEvent(),getHandledExceptionQueuedEvents(),getRootCause(Throwable),processEvent(SystemEvent)andisListenerForSource(Object). (The specification text uses the shorter namesgetHandledExceptionEvent,getHandledExceptionEventsandgetUnhandledExceptionEvents; the 4.1 API documentation is what compiles.) -
jakarta.faces.context.ExceptionHandlerWrapperimplementsFacesWrapper<ExceptionHandler>and calls through to the wrapped handler for every method. You override onlyhandle(). The constructor takes the wrapped handler; the no-argument one is deprecated. -
jakarta.faces.event.ExceptionQueuedEventcarries anExceptionQueuedEventContext, available fromgetContext(). -
FacesContext.getExceptionHandler()andsetExceptionHandler()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:
<?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 |
|---|---|
|
The throwable that was queued. It is usually wrapped (Unwrapping). |
|
The component being processed, or |
|
The phase being processed. The |
|
The |
|
A map for your own data. Use |
|
Tell that the exception came from the |
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:
-
Takes the event out of the queue (
Iterator.remove()), so that no other handler also acts on it. -
Unwraps it to the real cause.
-
Ignores
AbortProcessingException(logs it atFINE). -
For a
ViewExpiredException, remembers a redirect to the same view with?expired=true. -
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/. -
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.
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());
}
}
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;
}
}
Source: Jakarta Faces 4.1 API: ExceptionHandlerWrapper and Jakarta Faces 4.1 specification, 6.2 ExceptionHandler
Results with the handler and the error-page entries of the earlier section, Production, on both implementations:
| Request | Plain (non-Ajax) | Ajax |
|---|---|---|
|
HTTP 302 to |
HTTP 200, |
A postback with the view state of another session |
HTTP 302 to |
HTTP 200 with a |
|
HTTP 200, the page redisplayed, nothing logged above |
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:
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:
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 |
|
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
SilentExceptionHandlerdid that: the action threw, HTTP 200 came back with the form redisplayed, and nothing was logged at all (the log held no trace of theIllegalStateException). 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 withinstanceof. -
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.
| Option | What happens | Verified |
|---|---|---|
|
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 |
A handler that redirects to the same view (the example above) |
|
Both implementations, in curl and in Chrome (plain and Ajax) |
OmniFaces |
Suppresses the exception and redirects to the current URL with its query string, and sets the flash attribute |
Both implementations, in Chrome (plain and Ajax); the page read |
OmniFaces |
Renders your |
Both implementations, in Chrome |
A script on the client ( |
Reloads the page when |
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
Productionmessage is the fixed textSee 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, unlessWRAP_TAG_EXCEPTIONS_AS_CONTEXT_AWAREisfalse, when it names the real class like Mojarra without the prefix. -
An expired view arrives as an
errorelement 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.
/* 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;
}
});
}());
<!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:
<?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>
<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>
<!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.xmlandweb-fragment.xml. The location of status500or ofjava.lang.Throwableis required; without one, the run loggedIllegalArgumentException: Either HTTP 500 or java.lang.Throwable error page is required in web.xml or web-fragment.xmland the container’s own report was served. TheThrowablepage takes precedence over all others; with specific exception types, use500as 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.ViewRootwith 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, inProduction. -
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 loggedFullAjaxExceptionHandler: the FacesExceptionFilter has been automatically installed at URL pattern of /*. The filter unwrapsFacesExceptionandELExceptionbefore the container matches an error page, which fixed the MyFaces problem of Which error-page Matches: with anexception-typemapping forBookNotFoundException, MyFaces renderednot-found.xhtmlfor both a plain and an Ajax request. -
Context parameters.
org.omnifaces.EXCEPTION_TYPES_TO_UNWRAPadds exception types to unwrap (for examplejakarta.ejb.EJBException), andorg.omnifaces.EXCEPTION_TYPES_TO_IGNORE_IN_LOGGINGsuppresses OmniFaces’s own log record for the listed types (standard Faces and container logging is not affected). -
Rendering failures need
jakarta.faces.FACELETS_BUFFER_SIZEto 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 theMutableRequestFilterinstalled (since OmniFaces 5.4); without it they are dropped and a warning is logged. -
Subclassing.
findExceptionRootCause,shouldHandleExceptionRootCause,findErrorPageLocationandlogExceptionare 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-responseshape (MyFaces addsid="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
markAsServerErrorlistener 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. -
FacesMessageExceptionHandleradds every exception as a globalFATALFacesMessage; the application is responsible for showing it (h:messages, updated by Ajax), and it cannot handle exceptions in Render Response. It was not run. -
ExceptionSuppressorsuppresses the exception types listed inorg.omnifaces.EXCEPTION_TYPES_TO_SUPPRESSand 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:
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;
}
}
<!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 |
|---|---|---|---|---|
|
logged, swallowed (HTTP 200) |
queued, reached the handler |
queued, reached the handler |
queued |
|
queued, reached the handler |
queued |
logged ( |
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:
-
One log record per failure, at
SEVERE, with the stack trace, from the code that handles it. -
A reference in the record and on the error page, so a support ticket that quotes the reference leads straight to the record.
-
The phase and the component (client id) when the implementation provides them, and the unwrapped root cause.
-
Expected conditions at a lower level: an expired view at
INFOand an aborted request atFINE. -
Nothing sensitive: no request parameters (the
Developmentpage lists them for a reason), no headers or cookies, no passwords or tokens, no whole objects whosetoString()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. -
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 |
|
Ajax request, exception in an action |
|
|
Expired view (plain / Ajax) |
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:
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;
}
}
<!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. TheDevelopmentpages print stack traces, the component tree and the request parameters. -
No raw container report. Map
404and500(and the other status codes the application can produce, such as403) inweb.xml, and on Tomcat setshowReport="false"andshowServerInfo="false"on theErrorReportValveof 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 clientonerrorpolicy for the rest. -
The error page says little. A fixed sentence and a reference. No
jakarta.servlet.error.*values, nopfExceptionHandlermessage or stack trace, no request parameters, and noescape="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>inweb.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):
<Host name="localhost" appBase="webapps">
<Valve className="org.apache.catalina.valves.ErrorReportValve" showReport="false" showServerInfo="false"/>
</Host>
Choosing a Strategy
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,
ContextAwareELExceptionon MyFaces (unlessWRAP_TAG_EXCEPTIONS_AS_CONTEXT_AWAREisfalse, or the OmniFaces filter unwraps).getRootCause()does not unwrap it. -
ProductionAjax 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 gaveIllegalArgumentException. -
AbortProcessingExceptionfrom anactionmethod: reaches Mojarra’s default handler (HTTP 500), ignored by MyFaces’s. -
ExceptionQueuedEventContext.getComponent()for an action exception:nullon Mojarra, the command component on MyFaces. -
PhaseId.toString():INVOKE_APPLICATION 5versusINVOKE_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_LOGGINGworked; 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 |
|
Faces 2.0 |
|
Faces 2.2 |
The change list says that |
Faces 2.3 |
Issue 1401: the |
Faces 3.0 / 4.0 |
The package and parameter names changed from |
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 |
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.facespackages andjavax.faces.*parameters of that era arejakarta.facessince 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 thatAbortProcessingExceptionis 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 PrimeFacesExceptionHandlerand 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 standardfaces.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
FullAjaxExceptionHandlerand 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 noerror-pageis 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.Throwablenext to specific mappings. On Tomcat it shadows them. Use500. -
An
exception-typemapping that never matches on MyFaces. The exception arrives wrapped. Unwrap (OmniFaces) or setWRAP_TAG_EXCEPTIONS_AS_CONTEXT_AWAREtofalse. -
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.
-
NavigationHandlerin a handler during Render Response. Prohibited; redirect through theExternalContext. -
Redirecting after the response is committed. It cannot work; check
isResponseCommitted()and size the buffer. -
AbortProcessingExceptionbecomes 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 unwrapContextAwareELException. -
A secret in the log. Exceptions and parameters carry data; log the reference and the cause only.