API Error Responses
Available Error Messages and HTTP Status Codes¶
The system defines a hierarchy of HTTP responses under the HttpResponse sealed interface. For errors, the primary type is ErrorResponse. The cards.arda.common.lib.api.rest.types.Responses object provides a comprehensive set of factory functions to create standardized ErrorResponse instances, each corresponding to a specific HTTP status code.
These include, but are not limited to:
- Client Errors (4xx):
*Responses.BadRequest(message, details): 400 Bad Request
*Responses.Unauthorized(message, details): 401 Unauthorized
*Responses.Forbidden(message, details): 403 Forbidden
*Responses.NotFound(message, details): 404 Not Found
*Responses.MethodNotAllowed(message, details): 405 Method Not Allowed
*Responses.Conflict(message, details): 409 Conflict
*Responses.UnprocessableEntity(message, details): 422 Unprocessable Entity
*Responses.TooManyRequests(message, details): 429 Too Many Requests - Server Errors (5xx):
*Responses.InternalServerErrorResponse(message, details): 500 Internal Server Error
*Responses.NotImplemented(message, details): 501 Not Implemented
*Responses.BadGateway(message, details): 502 Bad Gateway
*Responses.ServiceUnavailable(message, details): 503 Service Unavailable
*Responses.GatewayTimeout(message, details): 504 Gateway Timeout
Each of these functions takes a message (String) and an optional details (JsonElement?).
JSON Format of Error Messages¶
All error responses generated by the system conform to the ErrorResponse data class structure:
@Serializable
data class ErrorResponse(
override val responseMessage: String,
override val code: Int, // HTTP Status Code
override val details: JsonElement? = null
) : Throwable(if(details == null) responseMessage else "$responseMessage: $details"), HttpResponse
responseMessage: A human-readable message describing the error.code: The HTTP status code (e.g., 400, 500).details: An optionalJsonElementproviding more specific information about the error. This field is crucial for conveying structured error data.
Structure of details¶
The details field can take several forms:
- Simple Cause: If an error has a
causethat is also anErrorResponse, thedetailsfield will contain the JSON representation of that cause. - Contextual Information (
SingleDetails): ForAppErrorinstances that have acontext(a lazy message provider) and acause, thedetailsfield is structured asSingleDetails:
- Composite Errors (
CompositeDetails): ForAppError.Compositeerrors, which represent multiple underlying issues, thedetailsfield is structured asCompositeDetails:
Serialization to JSON is handled by JsonConfig.standardJson.
Example JSON Output for a Composite Error:
{
"responseMessage": "Multiple errors occurred",
"code": 500,
"details": {
"context": "Validating user input",
"errors": [
{
"responseMessage": "username is Invalid: cannot be empty",
"code": 400,
"details": null
},
{
"responseMessage": "email is Invalid: must be a valid email format",
"code": 400,
"details": null
}
]
}
}
Ktor Integration through StatusPages¶
The system leverages Ktor’s StatusPages plugin to provide centralized exception handling and consistent API error responses. This plugin allows you to configure how different types of exceptions are converted into HTTP responses.
In Component.kt, the configureServer function (and by extension, configureOpenApiServer) installs and configures the StatusPages plugin:
// From Component.kt
install(StatusPages) {
exception<Throwable> { call, ktorExc ->
// Prioritize the cause if the Ktor exception message is the same as its cause's message
val toProcess = if(ktorExc.message == ktorExc.cause?.message) ktorExc.cause else ktorExc
// Convert the exception to an ErrorResponse
val response = toProcess.toErrorResponse()
// Log the error
app.log.warn("Error: {}", call.request.path(), toProcess)
// Respond with the appropriate HTTP status code and the ErrorResponse object as the body
call.respond(status=response.httpCode, message=response)
}
}
Key aspects of this integration:
- Global Exception Handler: A handler is registered for
Throwable. This means any unhandled exception that propagates up to Ktor will be caught by this handler. - Prioritizing the Cause: The code attempts to get to the root cause of an exception, especially in cases where Ktor might wrap an original exception. If
ktorExc.messageis the same asktorExc.cause?.message, it means Ktor likely just wrapped the original exception without adding new information, soktorExc.causeis used. Otherwise,ktorExcitself is processed. toErrorResponse()Extension Function: The core of the transformation lies in theThrowable?.toErrorResponse(normalizing: Boolean = true): ErrorResponseextension function (defined inHttpResponses.kt). This function is responsible for converting anyThrowableinto a standardizedErrorResponse.- Logging: The error, along with the request path, is logged using
app.log.warn. - Responding: The client receives a response with the HTTP status code derived from the
ErrorResponse(response.httpCode), and the body of the response is the JSON serialization of theErrorResponseobject itself.
Transformation Logic in toErrorResponse()¶
The toErrorResponse() function has the following logic:
- Null Receiver: If the
Throwableisnull, it defaults to anErrorResponsefor an internal server error (“Unexpected Error”, 500). AppErrorInstances: If theThrowableis already anAppError, it’s passed toResponses.appErrorResponse(this: AppError).
*Responses.appErrorResponsethen maps specificAppErrorsubtypes to appropriateErrorResponsefactory functions (e.g.,AppError.NotFoundmaps toResponses.NotFound,AppError.ArgumentValidationmaps toResponses.BadRequest).
* Thedetailsfield of theErrorResponseis populated using thecauseDetails()extension function, which includes context from theAppErrorand a representation of its cause.
*AppError.Compositeerrors are specifically handled byResponses.compositeErrorResponse, which serializes the list of underlying errors into thedetailsfield.- Other
ThrowableInstances:
* Ifnormalizingistrue(the default), theThrowableis first converted to anAppErrorusingthis.normalizeToAppError(). The resultingAppErroris then processed as described above. This ensures that common exceptions likeIllegalArgumentExceptionorSerializationExceptionare mapped to meaningfulAppError.GeneralValidationorAppError.Implementationtypes, which then translate to appropriate HTTP error codes (typically 400 or 500).
* Ifnormalizingisfalse, a genericResponses.InternalServerErrorResponseis created using the exception’s message and itscauseDetails().
causeDetails() Extension Function¶
The Throwable?.causeDetails(): JsonElement? function is responsible for creating the JsonElement for the details field of an ErrorResponse.
- If the
Throwableis anAppError:
* It checks ifthis.context(a lazy message) is available. If so, it creates aSingleDetailsobject containing the context string and theErrorResponseof itscause.
* If there’s no context, it directly uses theErrorResponseof itscause. - For other
Throwabletypes, it simply gets theErrorResponseof itscause. - The resulting
ErrorResponse(orSingleDetails) is then encoded to aJsonElementusingJsonConfig.standardJson.encodeToJsonElement().
This mechanism ensures that whenever an exception is thrown during request processing, it is consistently handled, logged, and transformed into a structured JSON error response that clients can parse and understand. The use of AppError allows for domain-specific error representation, which is then mapped to standard HTTP error responses.
How to Respond with an Error by Throwing an Exception¶
As detailed in the “Ktor Integration through StatusPages” section, the system is configured to catch any Throwable that propagates to the Ktor request pipeline. This means that the most straightforward way to respond with a standardized error is to throw an exception from your route handler. The StatusPages plugin will then use the toErrorResponse() extension function to convert this exception into the appropriate JSON ErrorResponse and HTTP status code.
While any Throwable can be thrown, it is best practice to throw instances of AppError (or exceptions that will be reliably normalized to a specific AppError via normalizeToAppError()). This gives you more precise control over the resulting error message, HTTP status code, and any included details.
Example: Directly throwing an AppError
fun Route.myCustomRoute() {
get("/my-resource/{id}") {
val id = call.parameters["id"] ?: throw AppError.ArgumentValidation("id", "ID path parameter is missing")
if (id == "forbidden_id") {
throw AppError.NotAuthorized("get my-resource", "user_xyz", context = { "Attempted to access restricted ID: $id" })
}
// ... logic to fetch resource ...
val resource = fetchResource(id) ?: throw AppError.NotFound("MyResource", context = { "Resource ID: $id" })
call.respond(resource)
}
}
fun fetchResource(id: String): String? {
// Dummy implementation
return if (id == "existing_id") "Data for $id" else null
}
In this example:
- If the
idparameter is missing, anAppError.ArgumentValidationis thrown, which will result in a 400 Bad Request. - If
idis “forbidden_id”, anAppError.NotAuthorizedis thrown, resulting in a 403 Forbidden. - If
fetchResourcereturnsnull, anAppError.NotFoundis thrown, resulting in a 404 Not Found.
Integrating with Functions Returning Result<T>¶
Often, business logic or service layer functions will return a Result<T> to encapsulate success or failure. When integrating such functions into your Ktor routes, you need to handle the failure case and convert it into an exception that StatusPages can process.
The most direct way to do this is by using Result.getOrThrow(). If the Result is a success, it returns the value; if it’s a failure, it throws the encapsulated Throwable.
Example: Using Result.getOrThrow()
// Assume this service function returns Result<String>
fun myServiceCall(id: String): Result<String> {
if (id.isBlank()) {
return Result.failure(AppError.ArgumentValidation("id", "Service ID cannot be blank"))
}
if (id == "error_id") {
return Result.failure(AppError.InternalService("DownstreamService", "Failed to process $id"))
}
return Result.success("Service data for $id")
}
fun Route.serviceIntegrationRoute() {
get("/service-resource/{id}") {
val id = call.parameters["id"]!! // For brevity, assume id is present
try {
val serviceData = myServiceCall(id).getOrThrow() // Throws if myServiceCall returns Result.failure
call.respond(serviceData)
} catch (e: Throwable) {
// Option 1: Re-throw if you want StatusPages to handle it directly (common case)
// The exception 'e' will be whatever was in Result.failure (e.g., AppError.ArgumentValidation)
throw e
// Option 2: Catch, potentially wrap/normalize, then re-throw for more specific handling if needed
// This is generally handled by the toErrorResponse() and normalizeToAppError() already,
// but shown for completeness if custom logic before StatusPages is desired.
// val appError = if (e is AppError) e else e.normalizeToAppError()
// throw appError
}
}
}
Pattern from DataAuthorityEndpoint.kt
The DataAuthorityEndpoint.kt uses a helper function responds which encapsulates the try-catch block and the call to getOrThrow().
// Simplified concept from DataAuthorityEndpoint.kt
suspend fun RoutingContext.respondsHelper(
block: suspend () -> Result<Unit>
) {
try {
block().getOrThrow() // If block() returns Result.failure, this throws the exception
} catch (e: Throwable) {
// In DataAuthorityEndpoint, there's a (deprecated) normalization here.
// More generally, you'd either let it propagate or ensure it's an AppError.
// For this example, we just re-throw to let StatusPages handle it.
throw e
}
}
// Usage in a route:
rt.get("/some-path") { // Assuming rt is a Route object
respondsHelper { // `this` is RoutingContext, `call` is ApplicationCall
val result = someOperationReturningResultUnit() // e.g., service.delete(...)
if (result.isSuccess) {
call.respond(HttpStatusCode.OK)
}
result // Return the result for getOrThrow()
}
}
In the DataAuthorityEndpoint.kt example:
// rt.openApiGet(entityIdPath, epSpec.getBuilder) { // This is Ktor's routing DSL context
// responds(epSpec.getOperationId, entityIdPath) { // `responds` is the helper
// call.withGetRequest { // `withGetRequest` prepares the request object
// service.getAsOf(eId, asOf).asyncFlatMap { // service.getAsOf returns a Result
// when(it) {
// null -> Result.failure(AppError.NotFound("$resourceName for ${this.eId}"))
// else -> call.recordRespond(it) // recordRespond itself likely returns Result<Unit>
// }
// }
// }
// }
// }
Here, the block passed to
responds ultimately returns a Result<Unit>. If any operation within that block (like service.getAsOf or call.recordRespond) results in a Result.failure, getOrThrow() (called inside responds) will throw the contained exception. This exception is then caught by the StatusPages plugin and converted into an HTTP error response.
This pattern ensures that errors encapsulated in Result types are consistently propagated as exceptions that the centralized error handling mechanism can process.