Skip to content

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 optional JsonElement providing 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:

  1. Simple Cause: If an error has a cause that is also an ErrorResponse, the details field will contain the JSON representation of that cause.
  2. Contextual Information (SingleDetails): For AppError instances that have a context (a lazy message provider) and a cause, the details field is structured as SingleDetails:
    @Serializable
    private data class SingleDetails(
      val error: ErrorResponse, // The ErrorResponse generated from the cause
      override val context: String?
    ): ErrorDetails
    
  3. Composite Errors (CompositeDetails): For AppError.Composite errors, which represent multiple underlying issues, the details field is structured as CompositeDetails:
    @Serializable
    private data class CompositeDetails(
      override val context: String?, // Context from the AppError.Composite
      val errors: List<ErrorResponse> // List of ErrorResponse objects for each cause
    ): ErrorDetails
    

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:

  1. 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.
  2. 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.message is the same as ktorExc.cause?.message, it means Ktor likely just wrapped the original exception without adding new information, so ktorExc.cause is used. Otherwise, ktorExc itself is processed.
  3. toErrorResponse() Extension Function: The core of the transformation lies in the Throwable?.toErrorResponse(normalizing: Boolean = true): ErrorResponse extension function (defined in HttpResponses.kt). This function is responsible for converting any Throwable into a standardized ErrorResponse.
  4. Logging: The error, along with the request path, is logged using app.log.warn.
  5. 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 the ErrorResponse object itself.

Transformation Logic in toErrorResponse()

The toErrorResponse() function has the following logic:

  • Null Receiver: If the Throwable is null, it defaults to an ErrorResponse for an internal server error (“Unexpected Error”, 500).
  • AppError Instances: If the Throwable is already an AppError, it’s passed to Responses.appErrorResponse(this: AppError).
    * Responses.appErrorResponse then maps specific AppError subtypes to appropriate ErrorResponse factory functions (e.g., AppError.NotFound maps to Responses.NotFound, AppError.ArgumentValidation maps to Responses.BadRequest).
    * The details field of the ErrorResponse is populated using the causeDetails() extension function, which includes context from the AppError and a representation of its cause.
    * AppError.Composite errors are specifically handled by Responses.compositeErrorResponse, which serializes the list of underlying errors into the details field.
  • Other Throwable Instances:
    * If normalizing is true (the default), the Throwable is first converted to an AppError using this.normalizeToAppError(). The resulting AppError is then processed as described above. This ensures that common exceptions like IllegalArgumentException or SerializationException are mapped to meaningful AppError.GeneralValidation or AppError.Implementation types, which then translate to appropriate HTTP error codes (typically 400 or 500).
    * If normalizing is false, a generic Responses.InternalServerErrorResponse is created using the exception’s message and its causeDetails().

causeDetails() Extension Function

The Throwable?.causeDetails(): JsonElement? function is responsible for creating the JsonElement for the details field of an ErrorResponse.

  • If the Throwable is an AppError:
    * It checks if this.context (a lazy message) is available. If so, it creates a SingleDetails object containing the context string and the ErrorResponse of its cause.
    * If there’s no context, it directly uses the ErrorResponse of its cause.
  • For other Throwable types, it simply gets the ErrorResponse of its cause.
  • The resulting ErrorResponse (or SingleDetails) is then encoded to a JsonElement using JsonConfig.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 id parameter is missing, an AppError.ArgumentValidation is thrown, which will result in a 400 Bad Request.
  • If id is “forbidden_id”, an AppError.NotAuthorized is thrown, resulting in a 403 Forbidden.
  • If fetchResource returns null, an AppError.NotFound is 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.

Comments