Error handling - waterdog-oss/ktor-template GitHub Wiki

Abstract:

Definition of the errors typically returned by REST APIs in the context of Waterdog projects.

Introduction/motivation:

HTTP status codes are important, but often don't convey all the required information to a client; In most cases the consumers are non-human API clients (web apps, mobile apps). Usually there are four broad categories of error:

  • 5XX errors: which are unexpected server errors, and usually there is very little the client can do.
  • 401 and 403 errors: related to authentication
  • 400 errors: usually some sort of validation error/invalid payload. For these errors it is usually important to capture details like the specific validation errors that occur in the request.
  • 4XX errors: other errors in the client request, usually due to resources not found (404) or errors in the content negotiation (415). For these cases the HTTP code is usually sufficient.

Requirements:

  • Machine readable format: JSON;
  • Errors should have a "code", that identifies the problem type;
  • Human friendly error info: the goal is to help the programmer of the client app to understand the error. The decision to surface that - information to the end user, rests on the client;
  • Format should have optional parameters to describe the specifics of an error, especially for the HTTP status 400 family of errors;

References:

Proposed Format

The proposed format does not strictly follow the RFC 7807 because in smaller projects it is uncommon to be able to provide meaningful URIs for error documentation. However it is trivial to update this class in order to comply with said RFC.

id: Unique identifier of the error occurrence.

title: A human friendly string that can help the programmer

messageCode: A string representing the generic i18n error code to be translated in the client. It should follow a "standard" format, for example: "client_error.not_found" to capture 404 errors.

errors: Optional list detailing all specific error information, e.g. specific invalid fields in a request.

errorCode: A string representing the specific error message, for instance: "error.users.invalid_username"

field: The specific field with an error (optional, as it may not apply to all cases).

args: Map with extra error information in the form of key-value pairs.


Examples:

a) Validation errors:

{
 "id": "9e74222e-02a8-4983-93d0-47c81e378457",
 "title": "Error inserting user. Please refer to https://...",
 "messageCode": "client_error.invalid_request"
 "args": [
  {
   "errorCode": "error.users.invalid_username",
   "field": "username",
   "args": {
    "minSize": 4,
    "maxSize": 12,
    "regex": "^[A-Za-z0-9_]{4,12}$"
   }
  },
  {
   "errorCode": "error.user.invalid_password",
   "field": "password",
   "rules": {
    "minSize": 10
   }
  }
 ]
}

b) Generic business rule violation:

{
 "id": "9e74222e-02a8-4983-93d0-47c81e378457",
 "title": "Operation not allowed for user. Please refer to https://...",
 "messageCode": "client_error.operation_not_allowed",
 "errors": [
  {
   "errorCode": "error.billing.complexUserRuleViolation"
   "args": {
       "key1": "value1",
       "key2": "value2"
   }
  } 
 ]
}

field field is optional and won't be returned if empty.

Field validation

We rely on an external library called valiktor to perform field to field validation. Check more details and source code at https://github.com/valiktor/valiktor

Field validation example

Say we have an entity called Car. In that entity we have 2 attributes: brand and model. Our business logic says that brand cannot be empty and must have between 3 and 10 characters. In our data class we can specify it like:

...
validate(Car::brand).hasSize(3, 10)
...

But where should we put this restrictions? Restrictions must be defined inside rules function of Validatable class. Any model/domain/DTO class that should be validated must extend Validatable abstract class and override rules method. That's the only required thing to do in order to define restrictions.

rules method receives a Validator<T> parameter that you must use in order to validate each field. Example:

override fun rules(validator: Validator<Car>) {
    validator
       .validate(Car::brand)
       .hasSize(3, 10)
    validator
       .validate(Car::wheels)
       .hasSize(4, 6)
       .validateForEach { it.applyRules(this) }
}

In this example, we apply the previously discussed brand restriction and we added a couple of new ones for wheels. Nothing new in the hasSize restriction, but notice that we call it.applyRules(this) inside validateForEach. As you can guess, for each element of wheels list the rules defined inside Wheels class will be applied. This is very useful, you can validate nested objects at once but set the rules independently inside each corresponding class.

Explaining it.applyRules(this). it is an instance of Wheel class and this is the anonymous function of type Validator<Wheel>

And finally, to validate a Validatable object:

instance.validate()

This method will throw an AppException when a rule is violated. That exception will be handled on ExceptionHandler file. With that in mind, if you are validating, for instance, a DTO received in some endpoint, you don't need to handle the AppException if you don't want to add any extra logic to it. The HTTP response will be built and send to the caller automatically. More details about this in the next section.

Exception handling

To centralize and facilitate the error messages management, we've created a new AppException that should be used for any controlled exception and is used to handle validation errors discussed before. This exception contains general error information and a list ErrorDefinition that contains all specific error data.

ErrorDefinition

  • errorCode: String
    • error message code to be used in clients i18n. Example: error.validation.username.size
  • field: String?
    • Optional. Tells the specific filed where the error occurred. Example: username
  • args: Map<String, String>
    • All useful information for the client. Example: { "min": 4, "max": 10 }

AppException

  • code: ErrorCode
    • Enum value telling the generic error message code and the specific http status code to be returned
    • This is a key piece, this enum must state exhaustively all possible errors and their corresponding http status code. This way you'll know exactly which http status code and message is returned to the client when you throw and AppException.
    • Example: InvalidUserInput(HttpStatusCode.BadRequest, "client_error.invalid_parameters")
    • Don't confuse this ErrorCode enum with ErrorDefinition.errorCode
      • ErrorCode is a mapping between http status code and a generic message code
      • ErrorDefinition.errorCode is a string representing a specific error code
  • title: String?
    • Optional human readable title
  • errors: List< ErrorDefinition >
    • List of specific errors

All data passed inside these exceptions will be catched by StatusPages exception handler and converted into an ErrorDTO. These errors will be serialized and sent to the client. ErrorDTO complies with the format defined here.

Each ErrorDTO has a unique id that will be logged on each exception. This id is sent to the client along all the error data, that should facilitate the error tracking.

⚠️ **GitHub.com Fallback** ⚠️