Exception Handling and Global Results
|
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 Apache Struts 7.4.0 (released 2026-10-02), running on Apache Tomcat 11.0.26 (Jakarta Servlet 6.1, Jakarta
Pages 4.0) with Java 21. Everything was verified on 2026-10-08: the facts against the Struts documentation, the
struts-default.xml, default.properties and DTD files inside the 7.4.0 struts2-core jar and the bytecode of
org.apache.struts2.interceptor.ExceptionMappingInterceptor, and every example by building it into a Maven WAR with the
struts2-bom and deploying it on Tomcat 11.0.26, where each URL was called with curl; the outputs shown are the outputs
that run produced (headers that do not matter and blank lines are left out). Action code throws exceptions: a book that does not exist, a database that is
down, a bug. What the visitor sees, what the operator finds in the log, and which HTTP status the browser, a proxy or a search
engine receives are three separate decisions, and Struts lets the configuration make them without a try block in every
action. The running scenario is the Bookshelf catalogue: the page grows from an action that throws and a stack trace in the
browser to a configuration in which every exception ends in a polite page, a correct status code and one log entry.
The Idea: An Exception Is Just Another Result Code
An action method returns a result code such as success or input, and the framework looks that code up in the action’s
result elements and then in the package’s global-results. Exception mapping adds one rule: when the action throws, the
exception is converted into a result code by a declaration, and from then on the request is processed as if the action had
returned that code. The Core Developers Guide calls this "a powerful feature for dealing with an Action class that throws an
Exception" and says it is especially useful for frameworks that throw runtime exceptions.
Three pieces cooperate:
-
exception-mappingelements say "an exception of this class (or a subclass) means this result code". -
resultandglobal-resultselements say what that code does: render a JSP, redirect, chain to another action, or send an HTTP error. -
The
exceptioninterceptor, an instance oforg.apache.struts2.interceptor.ExceptionMappingInterceptor, catches the exception, looks up the mapping and pushes the exception onto the value stack. Without that interceptor in the action’s stack the mappings are ignored, a point on which the interceptor’s documentation is explicit.
The Struts 1 Ancestor
Struts 1 (see Struts 1 (Classic) & Migration) had the same idea under other names. A global-exceptions block (or an exception child of an action mapping) in
struts-config.xml paired an exception type with a message key and a path to forward to, and an optional handler
class (a subclass of ExceptionHandler) could be plugged in when a forward was not enough. The Struts 2 version is purely
declarative: the "handler" is the result type, so a redirect, a chain, a stream or an HTTP error are all available without
writing a class. The snippet is a labelled "before" example and is not used anywhere else on this page.
<global-exceptions>
<exception key="error.catalogue" type="java.sql.SQLException" path="/error.jsp"/>
</global-exceptions>
The Elements
Everything lives in struts.xml, in a package that extends struts-default (which already registers the interceptor and
puts it first in the stacks). The element order is fixed by the DTD: inside package the sequence is result-types,
interceptors, default-interceptor-ref, default-action-ref, default-class-ref, global-results,
global-allowed-methods, global-exception-mappings and then the action elements, so global-results must come before
global-exception-mappings. Inside an action the children are param, result, interceptor-ref and exception-mapping
in any order, followed by an optional allowed-methods.
| Element | Attributes and content | Meaning |
|---|---|---|
|
|
Maps an exception class and its subclasses to a result code. Allowed inside |
|
one or more |
Mappings that apply to every action of the package and of the packages that extend it |
|
|
Results every action of the package can reach, including the ones named by exception mappings |
|
|
Local results are tried first, then global results, exactly as for a code returned by |
|
|
Activates the mapping and its logging; present in every stack of |
Worked Example: Global Mappings for the Bookshelf
The Bookshelf action book looks a book up by its id and throws in three situations: a missing book
(BookNotFoundException, a checked exception that extends Exception), a database that is down
(CatalogUnavailableException, a runtime exception that wraps an SQLException), and a bug (IllegalStateException).
public class BookDetailAction extends ActionSupport {
private static final Map<Integer, String> TITLES = Map.of(1, "Dune", 2, "Neuromancer");
private int id;
private String title;
@StrutsParameter
public void setId(int id) { this.id = id; }
public String getTitle() { return title; }
@Override
public String execute() throws Exception {
if (id == 500) {
throw new CatalogUnavailableException("catalog down",
new SQLException("connection refused: jdbc:postgresql://db.internal:5432/books user=bookshelf"));
}
if (id == 13) {
throw new IllegalStateException("unlucky book");
}
title = TITLES.get(id);
if (title == null) {
throw new BookNotFoundException("No book with id " + id);
}
return SUCCESS;
}
}
The configuration maps the three exceptions to three result codes. The specific ones answer with an HTTP error through the
httpheader result (its error parameter calls sendError, as the result’s documentation describes) and the general
java.lang.Exception mapping renders a neutral page.
<package name="default" extends="struts-default" namespace="/">
<global-results>
<result name="error">/WEB-INF/views/error.jsp</result>
<result name="unavailable" type="httpheader">
<param name="error">503</param>
<param name="errorMessage">Catalogue unavailable</param>
</result>
<result name="notfound" type="httpheader">
<param name="error">404</param>
<param name="errorMessage">No such book</param>
</result>
</global-results>
<global-exception-mappings>
<exception-mapping exception="org.example.bookshelf.CatalogUnavailableException" result="unavailable"/>
<exception-mapping exception="org.example.bookshelf.BookNotFoundException" result="notfound"/>
<exception-mapping exception="java.lang.Exception" result="error"/>
</global-exception-mappings>
<action name="book" class="org.example.bookshelf.BookDetailAction">
<result>/WEB-INF/views/book.jsp</result>
</action>
</package>
The error.jsp page is deliberately static, and the web.xml of the application declares what the container renders for the
HTTP errors that the httpheader result sends (the specification of that mechanism is the subject of
Servlet Fundamentals and of the Pages error-handling page).
<error-page>
<error-code>404</error-code>
<location>/WEB-INF/views/fallback.jsp</location>
</error-page>
<error-page>
<error-code>503</error-code>
<location>/WEB-INF/views/fallback.jsp</location>
</error-page>
<error-page>
<exception-type>java.lang.Throwable</exception-type>
<location>/WEB-INF/views/fallback.jsp</location>
</error-page>
Calling the application gives the following responses.
$ curl -s -i 'http://localhost:18080/g8exc/book?id=1'
HTTP/1.1 200
<h1>Dune</h1>
$ curl -s -i 'http://localhost:18080/g8exc/book?id=99' # BookNotFoundException, notfound result
HTTP/1.1 404
<h1>Fallback error page</h1>
<p>Status: 404</p>
$ curl -s -i 'http://localhost:18080/g8exc/book?id=500' # CatalogUnavailableException, unavailable result
HTTP/1.1 503
<h1>Fallback error page</h1>
<p>Status: 503</p>
$ curl -s -i 'http://localhost:18080/g8exc/book?id=13' # IllegalStateException, general Exception mapping
HTTP/1.1 200
<h1>Something went wrong</h1>
<p>The request could not be completed. Please try again later.</p>
The last answer shows the first pitfall of the plain dispatcher result: the page is correct but the status is 200. A
monitoring tool, a cache or a search engine believes the request succeeded. The pattern used for the first two exceptions is
better: let the exception result send the HTTP error and let the container’s error-page render it, so the status and the
body agree (see Status Codes: Do Not Return 200 for a Failure).
Which Mapping Wins
The interceptor does not simply read the mappings in order. The documentation says it "will find the closest class inheritance match between the Exception thrown and the Exception declared" and that it examines the local mappings first and then the global ones. The bytecode of the 7.4.0 interceptor shows what that means: it builds one list (local mappings, then global mappings), computes for every entry the number of superclass steps from the thrown class up to the declared class, and keeps the entry with the smallest distance; on a tie the earlier entry, that is the local one, is kept. A declared class that is not in the hierarchy of the thrown exception is skipped.
| Situation | Result | Why |
|---|---|---|
Local |
the local result |
Both are 2 steps away ( |
Global |
|
0 steps beats 1 step |
Local |
|
0 steps beats 2 steps. A broad local mapping does not shadow a more specific global one |
Local |
the local result |
Same distance, local first |
Only unrelated classes are mapped |
no result: the exception is rethrown |
Nothing matches, so the container sees the exception (see What the Mapping Does Not Catch) |
The "broad local does not shadow a specific global" row is not obvious from the guide, which speaks of "first local and then
global"; it was verified with the leaky action of the scratch application (local mapping java.lang.Exception, thrown
CatalogUnavailableException), which answered 503 through the global unavailable result while the same action rendered
its local result for the IllegalStateException of id=13.
A local mapping is declared inside the action. The Bookshelf lend action treats an unknown book as a form problem, not as
a 404, and so overrides the global mapping for the same exception; it also passes a param to show what that child element
does.
<action name="lend" class="org.example.bookshelf.LendAction">
<exception-mapping exception="org.example.bookshelf.BookNotFoundException" result="input">
<param name="reason">unknown</param>
</exception-mapping>
<result>/WEB-INF/views/lend.jsp</result>
<result name="input">/WEB-INF/views/lend-input.jsp</result>
</action>
<%@ taglib prefix="s" uri="/struts-tags" %>
<p>Unknown book: <s:property value="exception.message"/> (param: <s:property value="#parameters.reason"/>)</p>
$ curl -s -i 'http://localhost:18080/g8exc/lend?id=7'
HTTP/1.1 200
<p>Unknown book: No book with id 7 (param: unknown)</p>
The param children of an exception-mapping are appended by the interceptor to the invocation’s parameters, which is why
the expression #parameters.reason finds the value. The Convention plugin documents the same thing for its annotation
("the parameters defined by params are passed to the result").
From the Code to the Result
After the interceptor has chosen a mapping it returns the mapped code instead of the exception, and the framework treats it
like any other result code. The Core Developers Guide states the rule: it first looks for the result in the local action
mapping, and if not found, for a global result. A mapping that names a code with no result anywhere is a configuration
error, reported as "No result defined for action … and result …", and that new exception is no longer inside the
interceptor’s try block (see the matrix in What the Mapping Does Not Catch). Any result type can be the target: a dispatcher JSP, a
redirect to a login page, a redirectAction, a chain to an action that prepares an error page, an httpheader that
sends an HTTP error. The Core Developers Guide’s own example mixes a redirect for a security exception and a chain for an
SQLException.
The Bookshelf chain variant maps the database failure to a maintenance action instead of an HTTP error.
<action name="chained" class="org.example.bookshelf.BookDetailAction">
<exception-mapping exception="org.example.bookshelf.CatalogUnavailableException" result="maint"/>
<result name="maint" type="chain">
<param name="actionName">maintenance</param>
</result>
<result>/WEB-INF/views/book.jsp</result>
</action>
<action name="maintenance">
<result>/WEB-INF/views/maintenance.jsp</result>
</action>
$ curl -s -i 'http://localhost:18080/g8exc/chained?id=500'
HTTP/1.1 200
<h1>Maintenance</h1>
The exception and exceptionStack Values
When a mapping is found the interceptor calls publishException, whose default implementation wraps the exception in an
org.apache.struts2.interceptor.ExceptionHolder and pushes that holder onto the value stack. The holder has exactly two
getters, so a result can read two names:
| Name | Holder getter | Content |
|---|---|---|
|
|
The exception object. |
|
|
The stack trace as text, including the |
Both exist only for the result that follows a mapped exception, which is why a JSP that prints them is empty when it is
rendered by an action that did not throw. The 7.4.0 bytecode of the interceptor registers the holder and the exception class with the OGNL thread allowlist
(ThreadAllowlist.allowClassHierarchy) when it publishes them, which is what keeps these expressions working under the
Struts 7 allowlist. Subclasses can change what is published by overriding
publishException(ActionInvocation, ExceptionHolder); the documentation suggests adding logging there.
<%@ taglib prefix="s" uri="/struts-tags" %>
<h1>Error</h1>
<p><s:property value="exception"/></p>
<pre><s:property value="exceptionStack"/></pre>
That is the Getting Started tutorial’s error.jsp almost word for word, and it is the example the next section tells you not
to use in production. Requesting leaky?id=13 with it returns:
$ curl -s 'http://localhost:18080/g8exc/leaky?id=13'
<h1>Error</h1>
<p>java.lang.IllegalStateException: unlucky book</p>
<pre>java.lang.IllegalStateException: unlucky book
at org.example.bookshelf.BookDetailAction.execute(BookDetailAction.java:30)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at ognl.OgnlRuntime.invokeMethodInsideSandbox(OgnlRuntime.java:909)
...
The leaked trace names the class and line of the action, the OGNL internals, the Struts version’s package layout and, for the
CatalogUnavailableException, a Caused by line that would contain the JDBC URL and user name. That is the information an
attacker needs to choose an exploit, and the reason for the next two sections.
Logging What Was Handled
By default a handled exception is converted and forgotten: a request to an action whose stack has the plain exception
interceptor produced no log line in the scratch application. Logging is switched on with three interceptor parameters.
| Parameter | Default | Meaning |
|---|---|---|
|
|
When |
|
|
The level used: |
|
the interceptor’s class |
The logger category; set it to route the entries to your own appender (for example |
The parameters are given to the interceptor by name inside a stack, with the interceptor’s name as prefix. The tutorial
builds a stack that wraps defaultStack and makes it the package default, so every action logs its handled exceptions at
ERROR:
<interceptors>
<interceptor-stack name="bookshelfStack">
<interceptor-ref name="defaultStack">
<param name="exception.logEnabled">true</param>
<param name="exception.logLevel">ERROR</param>
<param name="exception.logCategory">bookshelf.errors</param>
</interceptor-ref>
</interceptor-stack>
</interceptors>
<default-interceptor-ref name="bookshelfStack"/>
With the Log4j2 default configuration (the log4j-slf4j2-impl binding of the scratch application) one request produced, on
the Tomcat console, the following; the user sees only the 503 page while the operator reads the cause with the connection
string:
2026-10-08T13:48:43.581456Z http-nio-18080-exec-822 ERROR catalog down
org.example.bookshelf.CatalogUnavailableException: catalog down
at org.example.bookshelf.BookDetailAction.execute(BookDetailAction.java:26)
...
Caused by: java.sql.SQLException: connection refused: jdbc:postgresql://db.internal:5432/books user=bookshelf
This is the "log everything with the stack trace in our own file and present a friendly page with no stack trace" pattern that the interceptor’s documentation names as the purpose of the parameters. The log is where secrets in exception messages belong, and the log file must be protected accordingly. Which logger writes and in which format is a logging-framework question; the Struts side (Log4j2 as the default binding, categories, levels) is covered on the debugging and logging page of this part.
Error Pages: Three Layers
Three mechanisms can produce the page the visitor sees. They nest, and a careful application uses all three.
| Layer | Configured in | Handles |
|---|---|---|
1. Exception mapping and result |
|
Exceptions thrown by the action method and by the interceptors inside the |
2. Dispatcher |
|
Anything that escapes layer 1 (no mapping, an exception outside the interceptor, an unknown action). With |
3. Container |
|
HTTP errors sent by |
An exception thrown while a result renders is inside layer 1: a result JSP of the scratch application that throws before its
buffer is flushed was mapped by the general java.lang.Exception mapping and the visitor received error.jsp; the text
written before the exception was discarded with the buffer. Once the response is committed nothing can change the page, which
is the same limit as in plain JSP error handling.
Status Codes: Do Not Return 200 for a Failure
A dispatcher result renders a JSP with whatever status the response has, normally 200. There are three ways to give a
failure the correct status, in order of preference.
-
Send the error and let the container render it. The result is an
httpheaderwith theerrorparameter (and an optionalerrorMessage), andweb.xmldeclares anerror-pagefor that code. This is the pattern used for404and503above, and the status of the response and the body agree. -
Set only the status. The
httpheaderresult also has astatusparameter that sets the status without callingsendError; it has no body of its own, so it suits204,201and redirect-like answers rather than error pages. -
Set it in the action. An action that implements
ServletResponseAware(or reads the response from theServletActionContext) callssetStatusbefore returning the code. The JSP then renders with that status.
The tutorial’s approach of a JSP for Exception is correct for the user and wrong for the status, and the scratch
application proves it. The strict action below maps any exception to a fatal result that sends a 500:
<global-results>
<result name="fatal" type="httpheader">
<param name="error">500</param>
<param name="errorMessage">Internal error</param>
</result>
</global-results>
<action name="strict" class="org.example.bookshelf.BookDetailAction">
<exception-mapping exception="java.lang.Exception" result="fatal"/>
<result>/WEB-INF/views/book.jsp</result>
</action>
$ curl -s -i 'http://localhost:18080/g8exc/strict?id=13'
HTTP/1.1 500
<h1>Fallback error page</h1>
<p>Status: 500</p>
The cost of the httpheader approach is that the error page of layer 3 cannot use the value stack. For an application that
needs a localised or branded error page with Struts tags, map the exception to a chain or redirectAction result that
leads to an error action, give that action’s JSP the content, and set the status in the action (way 3).
What the Mapping Does Not Catch
The interceptor wraps one call, invocation.invoke(), in try { ... } catch (Exception e). Everything that happens outside
that call, or is not an Exception, bypasses the mappings. The matrix lists what was exercised on Tomcat 11.0.26.
| Where it is thrown | Mapped? | What happened |
|---|---|---|
The action method, or code it calls |
yes |
The cases of this page |
A JSP result while rendering (before the buffer is committed) |
yes |
Mapped to the general |
An interceptor placed after |
yes |
Action |
An interceptor placed before |
no |
Action |
The action’s constructor |
no |
Action |
A mapped code with no |
no |
"No result defined for action … and result …" is raised after the interceptor has finished, and the generic page is shown (with devMode off) |
A subclass of |
no |
The |
An unknown action |
no |
No action runs. The dispatcher logs "Could not find action or result" and the container answers |
The interceptor must therefore be first in the stack, and the documentation says as much: "make this interceptor the first
interceptor on the stack, ensuring that it has full access to catch any exception, even those caused by other interceptors".
In struts-default.xml of 7.4.0 the exception interceptor is the first element of basicStack,
paramsPrepareParamsStack and defaultStack, and the other stacks are built on those. A hand-written stack that lists
exception second silently loses the first interceptor’s exceptions, which is the last row.
<interceptor-stack name="exceptionFirstStack">
<interceptor-ref name="exception"/>
<interceptor-ref name="broken"/>
</interceptor-stack>
<interceptor-stack name="exceptionLastStack">
<interceptor-ref name="broken"/>
<interceptor-ref name="exception"/>
</interceptor-stack>
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g8exc/first
200
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g8exc/last
500
Exceptions, Field Errors and Action Errors
An exception is for the unexpected. Expected failures such as a blank title, a non-numeric id or a password that does not
match are not exceptions: type-conversion failures are recorded by the conversionError interceptor, validation adds field
errors, and the workflow interceptor returns input when there are any. Those messages are shown with the actionerror
and fielderror tags; the Core Developers Guide’s sample error.jsp calls <s:actionerror/> next to the exception.
There is a trap in that arrangement that the scratch application exposed: book?id=abc is a conversion error, the
workflow interceptor therefore returns input, and the action book has no input result. The resulting "No result
defined for action … and result input" exception is mapped by the general Exception mapping, so the visitor got the
polite error.jsp and the actual mistake (a missing input result) is only in the log. A broad mapping hides
configuration errors from the developer; keep the log on and add an input result.
Never Show Stack Traces
The rules, in order of importance:
-
The response never contains
exceptionStack, a stack trace, an exception class name, a SQL statement, a file path or a connection string. The logged entry holds them; the visitor gets a sentence, an optional reference number and the status code. Theleakypage above is the example of what not to deploy. -
Print
exception.messageonly when you wrote the message for the visitor. TheBookNotFoundExceptiontext ("No book with id 7") is written for display; the message of a library exception such as anSQLExceptionis not.<s:property>escapes HTML by default (itsescapeHtmlattribute defaults totrue), but a message that includes user input must still never be switched toescapeHtml="false". -
struts.devModeis off in production. The Development Mode page opens with the warning to turn it off because it "can expose sensitive data of your application". Its effect on errors is concrete: withstruts.devMode=truethe scratch application answered the exception of the failing constructor, and a request for a nonexistent action, with HTTP200and the "Struts Problem Report" page, which lists the messages, the source file and line and the complete stack trace. A mapped exception such asbook?id=13still rendered its own page; only unmapped problems reach the report. -
Keep one fallback. An
error-pageforjava.lang.Throwableand for404/500inweb.xml(the third layer) is the safety net when the first two are misconfigured; without it Tomcat’sErrorReportValvewrites its own report. -
Do not embed request data in the log message without care, and keep secrets out of exception messages in the first place; the log is read by more people than the database is.
The constant struts.handle.exception is documented in default.properties as "Indicates if Dispatcher should handle
unexpected exceptions by calling sendError() or simply rethrow it as a ServletException to allow future processing by other
frameworks like Spring Security". In the scratch application both settings ended on the same web.xml fallback page for the
failing constructor (status 500), so the visible difference appears only with a filter further out that wants to see the
exception itself.
<constant name="struts.devMode" value="false"/>
<constant name="struts.handle.exception" value="true"/>
The Annotation Form
The Convention plugin offers the same mapping as annotations. @ExceptionMapping (with @ExceptionMappings for several)
takes exception, result and params, and can sit on the action class, which applies it to every action of that class, or
on a method through the exceptionMappings attribute of @Action. The plugin’s documentation shows the shape; it needs
struts2-convention-plugin in the project and is covered with the other annotations on the Convention Plugin & Annotations page of this part.
@ExceptionMappings({
@ExceptionMapping(exception = "java.lang.NullPointerException", result = "success", params = {"param1", "val1"})
})
public class ExceptionsActionLevelAction {
public String execute() throws Exception {
return null;
}
}
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
The mapping is ignored and the container shows its own error |
The stack of the action has no |
Use |
Exception pages return status |
The result is a |
Map to an |
The page for a broad |
A missing |
Add the missing result, and check the log: the real exception is there |
"No result defined for action … and result X" |
An |
Declare the result; global ones go in |
The XML fails to load with a DTD or "invalid content" error |
|
Follow the element order of the DTD |
Exceptions thrown by the constructor are not mapped |
Mappings apply to action methods and the interceptor chain, not to object creation |
Keep constructors trivial; do the work in |
|
The parameter is on an |
Put the parameter on the |
A local mapping seems to be ignored for one exception |
A global mapping names a class closer to the thrown exception, and the closest class wins whether it is local or global |
Map the specific class locally too, or change the global mapping |
Sensitive text in the browser |
The error JSP prints |
Remove it, keep it in the log, turn devMode off |
Old tutorials use |
The documentation snippet predates the rename to |
Use the names in the 7.4.0 |
|
The logger category’s level in the logging configuration is higher than |
Make the category’s level admit the chosen level |
What Changed Recently
| Change | Effect |
|---|---|
Struts 7.0: |
The interceptor is now |
Struts 7.4.0 (2026-10-02) |
The scratch checks above ran on it: the interceptor’s behaviour (closest match, local before global on ties, |
Struts 7 OGNL allowlist |
The |
Struts 7 defaults |
|
Jakarta namespace |
Error pages in |
Where the Books and the Docs Are Outdated
The four books the section consulted for concept coverage predate this version, and the official pages still carry traces of the history. The remarks are about what changed, not about the text of any book.
| Source of the advice | What is outdated |
|---|---|
Struts 1 book: |
The mechanism is now a declarative |
Struts 2.0 era material (package |
Packages moved to |
Core Developers Guide, "Snippet of struts-default.xml" |
Shows |
Core Developers Guide, |
Closes the |
Exception interceptor page |
Gives the default log category as |
Getting Started tutorial |
Its |
Any text that shows |
The default extension is |
Summary
| Task | How |
|---|---|
Turn an exception into a page |
|
Reach the page from every action |
|
Get the right status |
|
Log handled exceptions |
|
Show the exception to the page |
|
Catch unmapped problems |
|
Annotations |
|