Status: DRAFT β This document is awaiting review. Content may be incomplete or subject to change. Do not remove this banner until the document has been interactively reviewed and approved.
Hoist provides a structured exception hierarchy and centralized error handling pipeline that logs server-side exceptions and converts them into meaningful, consistent JSON responses for clients. The system serves three primary goals:
- Semantic HTTP status codes β Exceptions map to appropriate HTTP status codes (401, 403, 404, 400, 500) so that clients can distinguish authorization failures from server bugs without parsing error messages.
- Routine vs. unexpected errors β The
RoutineExceptionmarker interface separates expected business-logic errors (invalid input, missing data, insufficient permissions) from genuine bugs. - Consistent logging β Routine exceptions are logged at DEBUG level rather than ERROR, keeping production logs clean and actionable. All exceptions flow through a single pipeline that ensures consistent formatting and appropriate log levels.
- Consistent JSON error format β All exceptions are serialized to a standard JSON shape via
ThrowableSerializer, giving the hoist-react client a reliable contract for displaying errors.
Custom exceptions exist because generic Java exceptions (RuntimeException,
IllegalArgumentException) carry no HTTP semantics and no signal about whether the error is
expected. By throwing NotAuthorizedException instead of RuntimeException("forbidden"), the
framework automatically applies the correct status code (403), logs at the right level (DEBUG), and
tells the client this is a routine condition β all without any per-endpoint plumbing.
| File | Location | Role |
|---|---|---|
ExceptionHandler |
src/main/groovy/io/xh/hoist/exception/ |
Central exception processing β logging, HTTP status, JSON rendering |
HttpException |
src/main/groovy/io/xh/hoist/exception/ |
Base class for exceptions with an HTTP status code |
RoutineException |
src/main/groovy/io/xh/hoist/exception/ |
Marker interface β expected errors, logged at DEBUG |
RoutineRuntimeException |
src/main/groovy/io/xh/hoist/exception/ |
Concrete RuntimeException implementing RoutineException |
NotAuthorizedException |
src/main/groovy/io/xh/hoist/exception/ |
403 Forbidden β user lacks required role |
NotAuthenticatedException |
src/main/groovy/io/xh/hoist/exception/ |
401 Unauthorized β user is not authenticated |
NotFoundException |
src/main/groovy/io/xh/hoist/exception/ |
404 Not Found β unknown URL or resource |
ValidationException |
src/main/groovy/io/xh/hoist/exception/ |
Wraps GORM ValidationException with human-readable messages |
DataNotAvailableException |
src/main/groovy/io/xh/hoist/exception/ |
Data temporarily unavailable (e.g. startup, new business day) |
InstanceNotAvailableException |
src/main/groovy/io/xh/hoist/exception/ |
Server instance not yet ready for requests |
InstanceNotFoundException |
src/main/groovy/io/xh/hoist/exception/ |
Requested cluster instance does not exist |
SessionMismatchException |
src/main/groovy/io/xh/hoist/exception/ |
Client username does not match session user |
ExternalHttpException |
src/main/groovy/io/xh/hoist/exception/ |
HTTP call to an external service failed |
ClusterExecutionException |
src/main/groovy/io/xh/hoist/exception/ |
Serialization-safe wrapper for remote cluster task failures |
ClusterTaskException |
src/main/groovy/io/xh/hoist/cluster/ |
DTO for transferring exception data across cluster nodes |
ThrowableSerializer |
src/main/groovy/io/xh/hoist/json/serializer/ |
Jackson serializer β converts exceptions to JSON |
BaseController |
grails-app/controllers/io/xh/hoist/ |
Controller base class β catches unhandled exceptions |
HoistFilter |
src/main/groovy/io/xh/hoist/ |
Servlet filter β catches exceptions from auth/cluster checks |
HoistInterceptor |
grails-app/controllers/io/xh/hoist/ |
Grails interceptor β throws NotAuthorizedException/NotFoundException |
Utils |
src/main/groovy/io/xh/hoist/util/ |
Static handleException() entry point |
Throwable
βββ RuntimeException
β βββ HttpException β has statusCode property
β β βββ NotAuthorizedException β 403, implements RoutineException
β β βββ NotAuthenticatedException β 401, implements RoutineException
β β βββ NotFoundException β 404
β β βββ ExternalHttpException β status from remote server
β β
β βββ RoutineRuntimeException β implements RoutineException
β β βββ DataNotAvailableException β data temporarily unavailable
β β βββ InstanceNotAvailableException β server not ready
β β βββ InstanceNotFoundException β cluster instance not found
β β
β βββ ValidationException β implements RoutineException (wraps GORM errors)
β βββ SessionMismatchException β implements RoutineException
β βββ ClusterTaskException β DTO for cross-cluster exception transfer
β
βββ Exception
β βββ ClusterExecutionException β Kryo-safe wrapper for remote failures
β
βββ (any other Throwable) β caught and rendered as 500
RoutineException is a Java interface (not a class) that marks exceptions representing expected
application conditions. It carries no methods or fields β its only purpose is to signal the
framework's exception handling pipeline:
- Logging:
ExceptionHandler.shouldLogDebug()returnstrueforRoutineExceptioninstances, causing them to be logged at DEBUG level instead of ERROR. This prevents expected business errors (e.g. "user doesn't have ADMIN role") from triggering error monitoring. - HTTP status: When a
RoutineExceptionis not also anHttpException, it maps to400 Bad Requestrather than500 Internal Server Error. - Client display: The
isRoutineflag is included in the serialized JSON, allowing hoist-react to display the error as a user-facing message rather than an "unexpected error" dialog.
Classes implementing RoutineException: RoutineRuntimeException, NotAuthorizedException,
NotAuthenticatedException, ValidationException, SessionMismatchException,
DataNotAvailableException, InstanceNotAvailableException, InstanceNotFoundException.
Base class for exceptions that carry an explicit HTTP status code. Constructed with a message,
optional cause, and an integer statusCode:
class HttpException extends RuntimeException {
Integer statusCode
HttpException(String msg, Throwable cause, Integer statusCode) {
super(msg, cause)
this.statusCode = statusCode
}
}The ExceptionHandler.getHttpStatus() method reads statusCode directly from HttpException
instances (with one exception β ExternalHttpException, discussed below).
| Class | Status Code | Implements RoutineException? |
Typical Usage |
|---|---|---|---|
NotAuthenticatedException |
401 | Yes | Thrown by BaseAuthenticationService when a request cannot be authenticated |
NotAuthorizedException |
403 | Yes | Thrown by HoistInterceptor when user lacks required roles, or by app code for authorization failures |
NotFoundException |
404 | No | Thrown by HoistInterceptor when no controller method matches, or by app code for missing resources |
ExternalHttpException |
varies | No | Wraps failures from HTTP calls to external services; carries the remote status code but is not used for the response status (see below) |
ExternalHttpException status code handling. When ExceptionHandler.getHttpStatus()
encounters an ExternalHttpException, it intentionally ignores the statusCode property and falls
through to the default logic (returning 500). This prevents a downstream service's 401 or 403 from
being forwarded as the Hoist server's own response status.
RoutineRuntimeException is a concrete RuntimeException implementing RoutineException. It
serves as the general-purpose "expected error" class and as the base class for more specific routine
exceptions:
| Class | Default Message | Purpose |
|---|---|---|
RoutineRuntimeException |
(caller-provided) | General expected error β logged at DEBUG, sent as 400 |
DataNotAvailableException |
"Data not available" |
Requested data is temporarily unavailable (startup, new business day) |
InstanceNotAvailableException |
(caller-provided) | Server instance is not yet ready for requests |
InstanceNotFoundException |
(caller-provided) | Named cluster instance does not exist |
Wraps Grails' grails.validation.ValidationException to extract human-readable error messages
from GORM validation errors. The ExceptionHandler.preprocess() method automatically converts
incoming Grails ValidationException instances into Hoist ValidationException instances:
// In ExceptionHandler.preprocess():
if (t instanceof grails.validation.ValidationException) {
t = new ValidationException(t)
}This means application code does not need to catch or wrap GORM validation exceptions explicitly β they are transformed automatically during exception handling.
The central exception processing class, installed as a Spring bean (xhExceptionHandler). It
provides three capabilities:
-
handleException()β Preprocesses, logs, and optionally renders an exception to an HTTP response. Called indirectly viaUtils.handleException()byBaseController,HoistFilter,HoistInterceptor, andTimer. -
getHttpStatus()β Determines the HTTP status code for an exception. -
summaryTextForThrowable()β Produces a one-line summary string (e.g."Not Authorized [NotAuthorizedException]") for logging and admin stats.
Customization: ExceptionHandler can be overridden by defining an alternative Spring bean in
resources.groovy, though this is rarely needed. The preprocess() and shouldLogDebug()
methods are protected template methods available for override.
A Jackson StdSerializer<Throwable> that controls the JSON shape of all exceptions. Registered
automatically by JSONSerializer's static initializer.
If the exception implements JSONFormat, the serializer delegates to formatForJSON().
Otherwise, it produces a standard map:
[
name : t.class.simpleName,
message : t.message,
cause : t.cause?.message,
isRoutine: t instanceof RoutineException
]Entries with falsy values β null, false, empty strings, and 0 β are stripped.
The ExceptionHandler.getHttpStatus() method applies these rules in order:
| Condition | HTTP Status |
|---|---|
Exception is HttpException (but not ExternalHttpException) |
Use exception's statusCode property |
Exception implements RoutineException |
400 Bad Request |
| All other exceptions | 500 Internal Server Error |
Concrete mappings for the built-in exception types:
| Exception Class | Routine? | HTTP Status |
|---|---|---|
NotAuthenticatedException |
Yes | 401 Unauthorized |
NotAuthorizedException |
Yes | 403 Forbidden |
NotFoundException |
No | 404 Not Found |
RoutineRuntimeException |
Yes | 400 Bad Request |
DataNotAvailableException |
Yes | 400 Bad Request |
InstanceNotAvailableException |
Yes | 400 Bad Request |
InstanceNotFoundException |
Yes | 400 Bad Request |
ValidationException |
Yes | 400 Bad Request |
SessionMismatchException |
Yes | 400 Bad Request |
ExternalHttpException |
No | 500 Internal Server Error |
Any other RuntimeException |
No | 500 Internal Server Error |
Note that NotFoundException does not implement RoutineException β 404s hitting the server
typically indicate a client bug or misconfiguration that warrants investigation.
When an exception is rendered to an HTTP response, the body is a JSON object with this shape:
{
"name": "NotAuthorizedException",
"message": "You do not have the required role(s) for this action.",
"isRoutine": true
}| Field | Type | Present When | Description |
|---|---|---|---|
name |
string | Always | Exception class simple name (e.g. "NotAuthorizedException") |
message |
string | When non-null | The exception's message |
cause |
string | When exception has a cause | The cause exception's message |
isRoutine |
boolean | When true |
Indicates a RoutineException β client should display as a user message, not a bug report |
Fields with null values are omitted from the response. The isRoutine field is only present
when true β if absent, the client should treat the error as unexpected.
The HTTP Content-Type header is always set to application/json.
Hoist catches exceptions at three layers, ensuring that no unhandled exception escapes without a proper JSON error response:
HTTP Request
β
βββ HoistFilter.doFilter() β catches auth/cluster exceptions
β β
β βββ HoistInterceptor.before() β catches role/route check exceptions
β β
β βββ BaseController β catches controller action exceptions
β β
β βββ runAsync() β catches async action exceptions
HoistFilter β Wraps the entire filter chain in a try/catch. If clusterService.ensureRunning()
or authenticationService.allowRequest() throws, the exception is caught here. This handles
InstanceNotAvailableException (cluster not ready) and NotAuthenticatedException.
HoistInterceptor β Checks controller access annotations (@AccessRequiresRole, etc.)
before the controller action executes. Throws NotFoundException if no matching controller method
is found, or NotAuthorizedException if the user lacks the required role(s). Catches its own
exceptions and delegates to Utils.handleException().
BaseController β The handleException(Exception) method (a Grails convention) catches any
exception thrown during a controller action. The runAsync() wrapper catches exceptions from
asynchronous actions.
All three layers delegate to the same pipeline:
Utils.handleException(
exception: t,
renderTo: response, // HttpServletResponse β omitted in Timer context
logTo: this, // LogSupport β determines the logger name
logMessage: [...] // Optional context for the log entry
)ExceptionHandler.handleException() performs these steps:
-
Preprocess β
preprocess(t)converts GrailsValidationExceptionto HoistValidationException, then sanitizes the stack trace viaGrailsUtil.deepSanitize(). -
Log β If a
LogSupporttarget is provided, logs the exception at the appropriate level:- DEBUG if
shouldLogDebug(t)returnstrue(i.e.RoutineExceptioninstances) - ERROR otherwise
- DEBUG if
-
Render β If an
HttpServletResponseis provided:- Sets the HTTP status code via
getHttpStatus(t) - Sets
Content-Type: application/json - Writes the exception as JSON via
JSONSerializer.serialize(t)(which usesThrowableSerializer) - Flushes the response buffer
- Sets the HTTP status code via
Timer (the Hoist polling timer) also uses Utils.handleException(), but without a renderTo
response β exceptions in timers are logged only, not rendered to HTTP:
Utils.handleException(
exception: throwable,
logTo: this,
logMessage: "Failure in '$name'"
)Timer additionally captures a one-line error summary via
ExceptionHandler.summaryTextForThrowable() for display in admin stats.
Use RoutineRuntimeException when the error is expected and the message should be shown to the
user. The framework logs at DEBUG and returns 400:
// β
Do: Use RoutineRuntimeException for expected validation/business logic errors
if (!portfolio) {
throw new RoutineRuntimeException('Please select a portfolio before running this report.')
}// β
Do: Use NotAuthorizedException when the user lacks permission for a specific resource
if (!blob.isOwnedBy(username)) {
throw new NotAuthorizedException(
"User '$username' does not have access to JsonBlob with token '${blob.token}'"
)
}// β
Do: Use NotFoundException for resources that should exist but don't
def config = AppConfig.findByName(name)
if (!config) {
throw new NotFoundException("Config not found: $name")
}// β
Do: Use DataNotAvailableException when data will become available later
void getMarketData() {
if (!marketDataLoaded) {
throw new DataNotAvailableException('Market data is still loading. Please try again shortly.')
}
// ... return data
}GORM validation exceptions are automatically converted by ExceptionHandler.preprocess(). You do
not need to catch and wrap them:
// β
Do: Let GORM validation exceptions propagate naturally
def config = new AppConfig(name: name, valueType: valueType)
config.save(failOnError: true)
// If validation fails, Grails throws grails.validation.ValidationException,
// which ExceptionHandler converts to io.xh.hoist.exception.ValidationException
// with a human-readable message.
// β Don't: Manually catch and re-wrap GORM validation exceptions
try {
config.save(failOnError: true)
} catch (grails.validation.ValidationException e) {
throw new RuntimeException(e.errors.toString()) // Loses the RoutineException semantics
}// β
Do: Use RoutineRuntimeException for timeout/limit conditions the user can act on
if (System.currentTimeMillis() - startTime > MAX_QUERY_TIME) {
throw new RoutineRuntimeException('Query took too long. Log search aborted.')
}Applications can customize exception handling by providing an alternative Spring bean:
// grails-app/conf/spring/resources.groovy
beans = {
xhExceptionHandler(MyCustomExceptionHandler)
}// src/main/groovy/com/myapp/MyCustomExceptionHandler.groovy
class MyCustomExceptionHandler extends ExceptionHandler {
@Override
protected boolean shouldLogDebug(Throwable t) {
// Also suppress logging for a third-party exception type
return super.shouldLogDebug(t) || t instanceof ThirdPartyTimeoutException
}
@Override
protected Throwable preprocess(Throwable t) {
// Convert a third-party exception to a routine exception
if (t instanceof ThirdPartyTimeoutException) {
t = new RoutineRuntimeException(t.message)
}
return super.preprocess(t)
}
}The hoist-react client receives exception responses as JSON objects with the shape described in
JSON Error Format. The key field for client-side behavior is isRoutine:
-
isRoutine: trueβ The client displays themessageas a user-facing notification or inline message. These are expected conditions (e.g. validation failure, insufficient permissions) and the user can usually take action to resolve them. -
isRoutineabsent/false β The client treats the error as unexpected and may display a more prominent error dialog, offer to send an error report, or log the error via activity tracking.
The HTTP status code also drives client behavior:
| Status | Client Behavior |
|---|---|
| 401 | Triggers re-authentication flow |
| 403 | Displays "access denied" messaging |
| 404 | Typically indicates a client-side routing bug |
| 400 | Displays the error message to the user |
| 500 | Displays an "unexpected error" notification |
When controller actions are forwarded to other cluster instances (via ClusterService),
exceptions must cross process boundaries. ClusterTaskException captures the cause's class name,
message, JSON serialization (causeAsJson), and HTTP status code (causeStatusCode).
BaseController.renderClusterJSON() then renders the pre-serialized JSON and status code
directly, preserving the original exception's characteristics for the client.
Generic exceptions are logged at ERROR level and return a 500 status code. If the error is expected (e.g. bad user input), use the appropriate Hoist exception type:
// β Don't: Use generic RuntimeException for expected errors
if (name.isBlank()) {
throw new RuntimeException('Name is required') // Logs at ERROR, returns 500
}
// β
Do: Use RoutineRuntimeException for expected errors
if (name.isBlank()) {
throw new RoutineRuntimeException('Name is required') // Logs at DEBUG, returns 400
}Using RuntimeException for expected errors pollutes ERROR logs with noise, making it harder to
spot genuine bugs. It also causes the client to display an "unexpected error" dialog instead of a
helpful message.
BaseController already catches all exceptions and routes them through ExceptionHandler. Adding
your own try/catch around entire actions defeats this pipeline:
// β Don't: Wrap entire action in try/catch
def myAction() {
try {
def result = myService.doWork()
renderJSON(result)
} catch (Exception e) {
log.error('Error in myAction', e)
render(status: 500, text: 'Something went wrong')
}
}
// β
Do: Let exceptions propagate to BaseController's handler
def myAction() {
def result = myService.doWork()
renderJSON(result)
}The manual catch loses the RoutineException semantics, bypasses ThrowableSerializer (so the
client gets an unexpected response format), and double-logs errors since BaseController never
sees the exception.
When an HTTP call to an external service fails, do not re-throw it as a plain HttpException with
the remote status code. This is exactly why ExternalHttpException exists β its status code is
intentionally ignored by getHttpStatus():
// β Don't: Forward an external 401 as your server's response
catch (ExternalHttpException e) {
throw new HttpException(e.message, e, e.statusCode) // Client thinks *your* server returned 401
}
// β
Do: Let ExternalHttpException propagate, or wrap in RoutineRuntimeException with context
catch (ExternalHttpException e) {
throw new RoutineRuntimeException("Data feed unavailable: ${e.message}")
}If you catch an exception in service code to handle it gracefully, still log it β otherwise the failure is invisible:
// β Don't: Silently swallow exceptions
try {
refreshCache()
} catch (Exception e) {
// silently ignored β if this keeps failing, nobody will know
}
// β
Do: Log the exception even if you handle it gracefully
try {
refreshCache()
} catch (Exception e) {
logWarn('Cache refresh failed, using stale data', e)
}NotFoundException does not implement RoutineException. It is logged at ERROR and returns
404. This is by design β a 404 hitting the server usually indicates a client bug (bad URL) rather
than an expected user condition. If you need a routine "not found" response, use
RoutineRuntimeException with an appropriate message:
// This logs at ERROR β appropriate for unexpected missing routes
throw new NotFoundException()
// This logs at DEBUG β appropriate for user-searchable resources that may not exist
throw new RoutineRuntimeException("No results found for query: $query")