What
- Two paths: controller exceptions vs pre-controller (filters/interceptors) exceptions.
- Single response shape via
GlobalExceptionHandlerfor both paths.
Flow (who catches what):
- Controller throws → handled by
@ControllerAdvicemethods. - Filter/interceptor throws → not handled by
@ControllerAdviceunless you forward viaHandlerExceptionResolver.
Why
- Consistency: avoid container default error pages/messages.
- Control: send structured
GenericResponsewith proper HTTP codes.
How
- Global handler (
GlobalExceptionHandler):
@ControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(UnAuthorizedExceptionThrowable::class)
fun handleUnauthorized(ex: UnAuthorizedExceptionThrowable, req: WebRequest): ResponseEntity<GenericResponse<Nothing>> {
val body = GenericResponse<Nothing>(
responseType = ResponseType.FAIL,
message = ex.errorMessage,
code = ex.code,
type = ex.errorMessage
)
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body)
}
@ExceptionHandler(Exception::class)
fun handleGeneric(ex: Exception, req: WebRequest) =
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
GenericResponse<Nothing>(
responseType = ResponseType.ERROR,
message = ex.message ?: "Internal server error",
code = HttpStatus.INTERNAL_SERVER_ERROR.value(),
type = "Internal server error"
)
)
}
Explanation:
Centralizes error shape with
GenericResponseand correct HTTP status codes.Add more handlers for
Forbidden,BadRequest,TooManyRequestsetc. to return precise codes consistently (see yourGlobalExceptionHandler).Route filter errors to the handler (
JwtAuthenticationFilter):
override fun doFilterInternal(req: HttpServletRequest, res: HttpServletResponse, chain: FilterChain) {
try {
val token = jwtUtilMaster.getJwtAuth(req, mustReturnTokenOrElseCrash = true)
if (token == null) {
handlerExceptionResolver.resolveException(
req, res, null, Exception("JWT token is required for this endpoint")
)
return
}
// ... authenticate and continue ...
chain.doFilter(req, res)
} catch (e: Exception) {
handlerExceptionResolver.resolveException(req, res, null, e)
}
}
Explanation:
Filters run before controllers; exceptions here bypass
@ControllerAdviceby default.handlerExceptionResolver.resolveException(...)forwards the error into your global handler so clients always see the same response format.Prefer resolving (not writing to the response yourself) to avoid double-commit errors.
Use RuntimeException-based custom errors:
class UnAuthorizedException(
val errorMessage: String = "Unauthorized access",
val code: Int = 401
) : RuntimeException(errorMessage)
class UnAuthorizedExceptionThrowable(
val errorMessage: String = "Unauthorized access",
val code: Int = KeywordsAndConstants.TOKEN_NOT_VALID
) : RuntimeException(errorMessage)
Explanation:
Extending
RuntimeExceptionmatches Spring’s expectation and keeps stack propagation simple.Include a
codeand message to map directly to HTTP status and to yourGenericResponsebody.Rules
- Throw inside controllers → handled by
@ControllerAdviceautomatically. - Throw inside filters/interceptors → call
handlerExceptionResolver.resolveException(...)to forward to the global handler. - Prefer project custom exceptions to set correct HTTP codes and messages centrally.
- Do not write to
HttpServletResponsebefore resolving; let the handler choose the status/body.
- Throw inside controllers → handled by