ExceptionHandlers.kt
package org.openapitools.api
import jakarta.validation.ConstraintViolationException
import org.openapitools.model.ErrorResponse
import org.slf4j.LoggerFactory
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatusCode
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.context.request.WebRequest
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import org.springframework.web.reactive.function.client.WebClientException
import org.springframework.web.reactive.function.client.WebClientResponseException
import org.springframework.web.server.ResponseStatusException
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler
// Additional exception subclasses not generated by the OpenAPI generator
class ForbiddenException(
msg: String,
) : ApiException(msg, 403)
class ConflictException(
msg: String,
) : ApiException(msg, 409)
class UnauthorizedException(
msg: String,
) : ApiException(msg, 401)
class BadGatewayException(
msg: String,
) : ApiException(msg, 502)
class GatewayTimeoutException(
msg: String,
) : ApiException(msg, 504)
// Takes priority over the generated DefaultExceptionHandler in Exceptions.kt
@Order(Ordered.HIGHEST_PRECEDENCE)
@ControllerAdvice
class ApiExceptionHandler : ResponseEntityExceptionHandler() {
private val log = LoggerFactory.getLogger(javaClass)
@ExceptionHandler(value = [ApiException::class])
fun onApiException(ex: ApiException): ResponseEntity<ErrorResponse> {
when {
ex.code >= 500 -> log.error("Server error [status={}]: {}", ex.code, ex.message)
ex.code == 401 || ex.code == 403 -> log.warn("Auth error [status={}]: {}", ex.code, ex.message)
}
return ResponseEntity.status(ex.code).body(ErrorResponse(ex.message ?: "An error occurred"))
}
// Triggered when a ResponseStatusException is thrown in controllers
@ExceptionHandler(value = [ResponseStatusException::class])
fun onResponseStatusException(ex: ResponseStatusException): ResponseEntity<ErrorResponse> {
val errorMessage = ex.reason ?: ex.message
return ResponseEntity
.status(ex.statusCode)
.body(ErrorResponse(message = errorMessage))
}
@ExceptionHandler(value = [NotImplementedError::class])
fun onNotImplemented(ex: NotImplementedError): ResponseEntity<ErrorResponse> {
log.warn("Not implemented endpoint hit")
return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(ErrorResponse("Not implemented"))
}
// Triggered by @Valid on @RequestBody when a field constraint fails
override fun handleMethodArgumentNotValid(
ex: MethodArgumentNotValidException,
headers: HttpHeaders,
status: HttpStatusCode,
request: WebRequest,
): ResponseEntity<Any> {
val message = ex.bindingResult.fieldErrors.joinToString("; ") { "${it.field}: ${it.defaultMessage}" }
return ResponseEntity.status(status).body(ErrorResponse(message))
}
// Triggered when a path/query variable cannot be converted to the expected type (e.g. non-integer recipeId)
@ExceptionHandler(value = [MethodArgumentTypeMismatchException::class])
fun onMethodArgumentTypeMismatch(ex: MethodArgumentTypeMismatchException): ResponseEntity<ErrorResponse> =
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse("Invalid value for '${ex.name}': '${ex.value}'"))
// Triggered by @Validated on path/query parameters
@ExceptionHandler(value = [ConstraintViolationException::class])
fun onConstraintViolation(ex: ConstraintViolationException): ResponseEntity<ErrorResponse> {
val message = ex.constraintViolations.joinToString("; ") { it.message }
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse(message))
}
// Triggered when the request body is missing or cannot be parsed
override fun handleHttpMessageNotReadable(
ex: HttpMessageNotReadableException,
headers: HttpHeaders,
status: HttpStatusCode,
request: WebRequest,
): ResponseEntity<Any> = ResponseEntity.status(status).body(ErrorResponse("Missing or malformed request body"))
// Triggered when the AI service is unreachable or returns an HTTP error
@ExceptionHandler(value = [WebClientException::class])
fun onWebClientException(ex: WebClientException): ResponseEntity<ErrorResponse> {
val responseBody = (ex as? WebClientResponseException)?.responseBodyAsString
if (responseBody != null) {
log.error("AI service error: {} | body: {}", ex.message, responseBody)
} else {
log.error("AI service unavailable: {}", ex.message)
}
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(ErrorResponse("GenAI service unavailable or returned an unparseable response"))
}
// Fallback for all other standard Spring MVC exceptions (405, 406, 415, 404, etc.)
override fun handleExceptionInternal(
ex: Exception,
body: Any?,
headers: HttpHeaders,
statusCode: HttpStatusCode,
request: WebRequest,
): ResponseEntity<Any> {
if (statusCode.is5xxServerError) log.error("Unhandled error [status={}]", statusCode.value(), ex)
return ResponseEntity.status(statusCode).headers(headers).body(ErrorResponse(ex.message ?: "An error occurred"))
}
}