# Design: First-class error types **Author**: @aat ## Description FTL should support first-class errors: That is, it should be possible to return an error that is also a data structure. We will explicitly annotate FTL errors, then best-effort discover their use in verbs and add metadata. ## Motivation It's currently impossible to differentiate between different types of errors. For example, if callers want to differentiate between authentication errors and transport errors, or act on a specific type of error, there's no way to propagate that kind of information. A related consideration is that is convenient to be able to categorise errors into broad buckets without knowing the specific error types. This is useful in many situations, but particularly useful when translating to and from different protocols in that the categories allow the translation layer to automatically map to different error classes in foreign protocols. [gRPC status codes](https://grpc.github.io/grpc/core/md_doc_statuscodes.html) and [HTTP status codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) are examples of this. ## Goals - Allow errors to be categorised so that different errors can have a similar semantic intent. eg. "Retryable" - Include a builtin set of error categories, probably based on [gRPC's](https://grpc.github.io/grpc/core/md_doc_statuscodes.html) status codes. - Ability to write FTL-aware custom errors in each language. - Add errors as first-class data types. - Add error usage metadata to verbs. ## Non-goals - This design won't cover what additional builtin categories we might include, such as "Retryable", etc. Starting with the gRPC status codes seems a safe bet. - No support for errors in multiple categories, because it's not clear how we'd support this in languages with single inheritance. ## Design Errors may either be "category" errors with no other associated data and may optionally be categorised themselves, or structured errors. Category/subcategory errors have a default message, but that message can be overridden. All errors have an associated human-readable message. In the case of structured errors, the message is a template into which the fields of the data structure may be interpolated. Templates are best effort, with a default, and are used to make the error message usable in other languages. For example, an error message format defined in Kotlin will have the same output in Go, and so on. ### Category errors A category error is defined in the schema like so, and _may_ include a parent category: ``` error <name> template "<default message>" category <category>, ... ``` eg. ``` error UnauthenticatedError ``` ### Structured errors TODO: Add some concept of "cause" TODO: How do we deal with stack traces? It'd be nice to have these formatted per-language in the console. A structured error can be defined like so: ``` error AuthError { username String reason String } ``` A structured error may declare that it is of a particular error category: ``` error AuthError category UnauthenticatedError { username String reason String } ``` A template message can be defined as follows. If not specified a default template will be generated. ``` error AuthError category UnauthenticatedError template "authentication error for ${username}: ${reason}" username String reason String } ``` ### Error categories The initial set of error categories will be modelled on gRPC status codes. They will be defined in the `builtin` namespace like so: ``` error CancelledError template "cancelled" error UnknownError template "unknown" error InvalidArgumentError template "invalid" error DeadlineExceededError template "deadline" error NotFoundError template "not found" error AlreadyExistsError template "already exists" error PermissionDeniedError template "permission denied" error ResourceExhaustedError template "resource exhausted" error FailedPreconditionError template "failed precondition" error AbortedError template "aborted" error OutOfRangeError template "out of range" error UnimplementedError template "unimplemented" error InternalError template "internal" error UnavailableError template "unavailable" error DataLossError template "data loss" error UnauthenticatedError template "unauthenticated" ``` As with gRPC status codes, there will be a well-defined mapping from the builtin error categories to HTTP status codes. These will automatically be used by the FTL HTTP ingress gateway. Any uncategorised errors will be treated as InternalErrors. ### Go SDK Create a new error type and add a single annotation to a struct implementing the `ftl.Error` interface, which has this signature: ```go type Error interface { error Category() Error } ``` An example of a category error might be something like: ```go type ErrUnauthenticated string func (e ErrUnauthenticated) Error() string { if e == "" { return "unauthenticated" } return string(e) } func (ErrUnauthenticated) Category() Error { return ErrUnauthenticated("") } ``` An example sub-category might be: ```go type ErrBadUser string func (e ErrBadUser) Error() string { if e == "" { return "unauthenticated" } return string(e) } func (ErrBadUser) Category() Error { return ErrUnauthenticated("") } ``` An example of implementing a structured error might be the following. Note that the `Category()` method implementation must be completely static, and the `Error()` method implementation must only use `fmt.Sprintf()` with interpolated fields, or return a static string. This limitation is so that error metadata can be statically extracted into the schema. ```go //ftl:error type AuthError struct { Username string Reason string } func (a AuthError) Category() builtin.Error { return builtin.ErrUnauthenticated{} } func (a AuthError) Error() string { return fmt.Sprintf("authentication failure for %s: %s", a.Username, a.Reason) } //ftl:verb func Destroy(ctx context.Context, req Request) (Response, error) { if !authorised(req.Username) { return Response{}, AuthError{req.Username, "not authorised"} } return ErrBadUser("bad user!") } ``` The Go SDK and Java SDKs will include helper functions and types to aid in creating error types. The schema extractor will detect its usage and add the appropriate metadata, resulting in a schema somewhat like this: ``` module builtin { error Unauthenticated template "unauthenticated" error Cancelled template "cancelled" error Unknown template "unknown" ... } module example { error AuthError category builtin.Unauthenticated template "authentication failure for ${username}: ${reason}" { username String reason String } verb destroy(Request) Response +errors AuthError } ``` The message format and the category will be statically extracted from the code. ### Kotlin API There's been a lot of internal discussion about whether to use exceptions for FTL error handling in Kotlin. The main alternative is returning errors as values, which is becoming popular in the broader tech industry, as seen in modern languages such as Go and Rust. We've decided that supporting both is probably the most pragmatic solution, but we'll start with exceptions to reduce front-loading work. In the case of returning errors as values, we'll detect that the verb signature returns a `Result` type and switch to detecting returned FTL errors. In both cases the error types will be defined as subclasses of `xyz.block.ftl.Exception`. It's not clear how/if templates can be expressed in Kotlin, perhaps via the error annotation declaring an FTL error, or perhaps we just synthesise one (eg. for the example below `"AuthError(username=${username}, reason=${reason})"`) TODO: Unhandled exceptions automatically get converted into `InternalError`?? ```kotlin @Error(template = "${username}: ${reason}") data class AuthError val username: String, val reason: String, ): UnauthenticatedException() ``` Exception usage will be familiar to anyone who has used Kotlin: ```kotlin @Throws(AuthError, UnauthenticatedException) fun destroy(context: Context, req: Request): Response { if (!authorised(req.username)) { throw AuthError(username = username, reason = "not authorised") } throw UnauthenticedException(message = "you're a bad person!") return Response() } ``` Result usage will also be also familiar to anyone who's used Kotlin's `Result` type: ```kotlin fun destroy(context: Context, req: Request): Result<Response> { if (!authorised(req.username)) { return Result.failure(AuthError(username = username, reason = "not authorised")) } return Result.success(Response()) } ``` ## Rejected Alternatives I initially considered automatically discovering all non-stdlib errors used by a verb, defining them as errors in the schema, then adding usage metadata to the verb. I discarded this approach because I think it would be difficult to differentiate which errors should be FTL errors and which shouldn't. That said, if it turns out this isn't too difficult we could add support for it later. The chosen approach is straightforward, but we could get smarter later.