Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Editoast error management #237

Open
wants to merge 27 commits into
base: master
Choose a base branch
from
Open
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
07f8a25
docs: add editoast error management proposal
leovalais Sep 18, 2024
e7a15f4
fix typos
leovalais Nov 8, 2024
c9bb82d
some adjustments
leovalais Nov 8, 2024
e670075
proposition: rename EditoastError to ViewError
leovalais Nov 8, 2024
c2fbf6a
add reasons for the new system
leovalais Nov 26, 2024
717e31d
add a section Rejected Ideas
leovalais Nov 26, 2024
ec3e8d2
add a section about Core errors
leovalais Nov 26, 2024
f735332
add implementation plan
leovalais Nov 26, 2024
1813559
workshop
leovalais Nov 26, 2024
cb0bf8b
Update content/docs/reference/design-docs/editoast-errors/_index.en.md
leovalais Nov 27, 2024
da75bbd
Update content/docs/reference/design-docs/editoast-errors/_index.en.md
leovalais Nov 27, 2024
46336d8
Update content/docs/reference/design-docs/editoast-errors/_index.en.md
leovalais Nov 27, 2024
ab758a5
fix inconsistency
leovalais Dec 2, 2024
db47135
Update _index.en.md
leovalais Jan 6, 2025
0581f19
workshop with @flomonster conclusions
leovalais Jan 7, 2025
3c86cc6
no
leovalais Jan 7, 2025
489c7be
more info about context
leovalais Jan 7, 2025
c28a470
remove CoreError.additional_metadata — bad idea
leovalais Jan 7, 2025
0e29b95
Update content/docs/reference/design-docs/editoast-errors/_index.en.md
leovalais Jan 7, 2025
558e345
Update content/docs/reference/design-docs/editoast-errors/_index.en.md
leovalais Jan 7, 2025
5e5a2f8
r/redis/valkey
leovalais Jan 7, 2025
de611a9
Update content/docs/reference/design-docs/editoast-errors/_index.en.md
leovalais Jan 7, 2025
81ffc41
:)
leovalais Jan 7, 2025
adea77c
hopefully better wording
leovalais Jan 8, 2025
3e611c6
IntoResponse
leovalais Jan 10, 2025
f7bb491
\#
leovalais Jan 10, 2025
accb041
a new derive macro 😏
leovalais Jan 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
320 changes: 320 additions & 0 deletions content/docs/reference/design-docs/editoast-errors/_index.en.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
---
title: "Editoast error management"
linkTitle: "Editoast error management"
weight: 80
---

# Goals

- Have a clear separation between logically distinct errors.
- Dispose of a way to actually match on errors when they occur deeper in the stack
- Tie the errors to the endpoint they originate from in the OpenAPI.
- Separate the error definition and emission of their serailization.
leovalais marked this conversation as resolved.
Show resolved Hide resolved
leovalais marked this conversation as resolved.
Show resolved Hide resolved
- Statuate on how we want to forward Core's errors.
leovalais marked this conversation as resolved.
Show resolved Hide resolved
leovalais marked this conversation as resolved.
Show resolved Hide resolved
- End the `InternalError` hegemony ⚔️

# Constraints

- Keep the same error format.
leovalais marked this conversation as resolved.
Show resolved Hide resolved
- The error must live until the error is handled, conversion to our standard error format only happen in the response serialization.
leovalais marked this conversation as resolved.
Show resolved Hide resolved
- Errors must implement `std::error::Error`.
- Errors must be composable.
leovalais marked this conversation as resolved.
Show resolved Hide resolved
- Errors variants must be shareable (e.g.: no multiple `InfraNotFound` variants).
leovalais marked this conversation as resolved.
Show resolved Hide resolved
leovalais marked this conversation as resolved.
Show resolved Hide resolved
- Each endpoint must provide all its error cases in the OpenAPI. (How the frontend will consume them is another problem that we'll have to deal with.)
- The `context` field of the error is populated in the views.
leovalais marked this conversation as resolved.
Show resolved Hide resolved
- Errors can be handled in a generic matter (for situations where it makes some sense to do so).
leovalais marked this conversation as resolved.
Show resolved Hide resolved
- I.e.: some form of downcasting is available.

# New system
woshilapin marked this conversation as resolved.
Show resolved Hide resolved

- Rely on `thiserror` everywhere.
- Keep the `trait EditoastError` but only implement it for errors defined in `views`.
- Rework `derive(EditoastError)` to interface it with `derive(thiserror::Error)`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need a derive macro?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could live without it, but having it would make assigning eg. error codes much simpler.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need for each error:

  • impl EditoastError for MyError (moderately tedious to write by hand)
  • impl utoipa::ToSchema for MyError (absolutely insane to write by hand)

If that's not convincing enough, manually implementing EditoastError for would really hinder the readability of the type. With the macro, all information about an error is gathered around its variant, but it will be scattered across all associated functions in the impl block.

I believe we're well above the threshold of acceptable boilerplate to justify the complexity of a proc macro ^^

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

- The `context` is empty by default but can be provided by the `impl EditoastError`. The macro is also able to take context providers.
- `EditoastError`'s `#[source]`, `#[from]`, `source` and `backtrace` fields are never serialized, unless explicitely provided. This shouldn't be the case as it exposes editoast internals at the API level.
leovalais marked this conversation as resolved.
Show resolved Hide resolved
- All errors must `impl Debug`.
leovalais marked this conversation as resolved.
Show resolved Hide resolved
- `derive(EditoastError)` generates the `impl utoipa::ToSchema for T`.
- Errors **do not** require to `impl Serialize`.
- `impl Serialize for T where T: EditoastError` however.
leovalais marked this conversation as resolved.
Show resolved Hide resolved
- Error cases that will be used repeatedly are defined as a `struct` but still `derive(thiserror::Error)`.
- The `error_type` of each variant is generated by the macro at the format `ErrorTypeName::VariantName`, but can be provided explicitely if conflicts arise.
leovalais marked this conversation as resolved.
Show resolved Hide resolved

## Nominal case

```rust
// in mod views;

fn get_resource(key: Key) -> Result<Resource, GetError> {
todo!()
}

#[derive(Debug, thiserror::Error)]
pub enum GetError {
#[error("ID not found")]
IdNotFound { id: u64 },
#[error("name not found")]
NameNotFound { name: String }
}

fn process_resource(resource: Resource) -> Result<Computation, ProcessingError> {
todo!()
}

#[derive(Debug, thiserror::Error)]
pub enum ProcessingError {
#[error("Resource is invalid")]
InvalidResource { resource: Resource },
#[error("Resource is too old")]
OutdatedResource { resource: Resource }
}

#[utoipa::path(
...,
responses(
(status = 200, body = Computation),
(status = 400, body = EndpointError)
)
)]
async fn endpoint(Path(key): Path<Key>) -> Result<Json<Computation>, EndpointError> {
let resource = get_resource(key)?;
let value = process_resource(resource)?;
Ok(Json(value))
}

#[derive(Debug, thiserror::Error, EditoastError)]
pub enum EndpointError {
#[error("Resource not found")]
#[editoast_error(user)] // <=> status = 400
ResourceNotFound(#[from] GetError),

#[editoast_error(internal)] // <=> status = 500, default, overrides the message to "Internal error - please contact your administrator"
ProcessingFailed(#[from] ProcessingError)
}
```

## Share errors between crates

Since we require no other constraint that `impl Error` for composition, it's easy to nest errors using `thiserror`.
leovalais marked this conversation as resolved.
Show resolved Hide resolved

````rust
// in editoast_models

#[derive(Debug, thiserror::Error)]
#[error("postgres error: {0}")]
struct DbError(#[from] diesel::Error);

// in editoast_redis (if we actually had that crate 👀)

#[derive(Debug, thiserror::Error)]
#[error("redis error: {0}")]
struct RedisError(#[from] redis::Error);

// in editoast_views, where diesel isn't available

#[derive(Debug, thiserror::Error)]
#[error("invalid resource form: {0}")]
struct FormError(ResourceForm);

#[derive(Debug, thiserror::Error, EditoastError)]
#[editoast_error(name = "CreateResourceError")] // schema name & error_type
enum CreateError {
#[error("will be overriden, but still useful for development")]
#[editoast_error(internal, name = "Database")]
Db(#[from] DbError),

#[error(transparent)] // shown to the user
#[editoast_error(user)]
InvalidForm(#[from] FormError)
}

async fn create(...) -> Result<Json<Resource>, CreateError> {
todo!()
}

#[derive(Debug, thiserror::Error, EditoastError)]
enum UpdateError {
#[editoast_error(internal)]
Db(#[from] DbError),

#[editoast_error(internal)]
Redis(#[from] RedisError),

#[error(transparent)]
#[editoast_error(user)]
InvalidForm(#[from] FormError)
}

async fn update(...) -> Result<(), UpdateError> {
todo!()
}
````

## Ease composability

### Nesting `EditoastError`s

We composed errors by nesting them thanks to `thiserror`. However, to compose and reuse `EditoastErrors`, we need a special flag so that when we attempt to serialize the error, we return the serialization of the source error directly.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section is not entirely clear to me. Why do we need something since:

  • all our errors implements thiserror::Error and therefore implements Display
  • we can then implements a Serialize based on Display
  • in case of nesting, the #[error(transparent)] basically forward the Display implementation to the sub-error

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As said in #237 (comment), Serialize doesn't produce the right format. We need to create for each ViewError that's being returned by an endpoint an instance of InternalError. Neither Display nor Serialize provide enough information for that. (And we're not even speaking about the OpenAPI schemas…)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


```rust
#[derive(Debug, thiserror::Error, EditoastError)]
#[error("no such infra: {id}")]
#[editoast_error(context, status = 404)]
struct InfraNotFound { id: u64 }

#[derive(Debug, thiserror::Error, EditoastError)]
#[error("unauthorized")]
#[editoast_error(status = 401)]
struct Unauthorized;

#[derive(Debug, thiserror::Error, EditoastError)]
enum EndpointError {
NotFound(#[from] #[editoast_error] InfraNotFound),

Unauthorized(#[from] #[editoast_error] Unauthorized)

#[error("oh no")]
#[editoast_error(user)]
Error1,
}
```

### Anonymous enums
leovalais marked this conversation as resolved.
Show resolved Hide resolved

Since we now have to really manage errors happening in every function **as precisely as possible**, there will likely be a lot of error enums going around. This may be a hassle and wrongfully encourage returning `Option` as an error. To circumvent that, an easy (albeit opinionated) QoL feature would be to use anonmous enums.
leovalais marked this conversation as resolved.
Show resolved Hide resolved

````rust
fn get_resource(id: u64) -> Result<Resource, Enum3<DbError, RedisError, MissingResourceError>> {
todo!()
}

fn process_resource(resource: Resource) -> Result<Computation, Enum2<DbError, ProcessingError> {
todo!()
}

#[utoipa::path(
...,
responses(
(status = 200, body = Computation),
(status = 400, body = EndpointError)
)
)]
async fn endpoint(Path(key): Path<Key>) -> Result<Json<Computation>, EndpointError> {
let resource = get_resource(key)?;
let value = process_resource(resource)?;
Ok(Json(value))
}

#[derive(Debug, thiserror::Error, EditoastError)]
pub enum EndpointError {
Db(#[from] DbError),

Redis(#[from] RedisError),

#[error(transparent)]
#[editoast_error(user)]
Missing(#[from] MissingResourceError)

#[error(transparent)]
#[editoast_error(user)]
Processing(#[from] ProcessingError)
}
````

The implementation of the `EnumX` type would be rather easy to generate for many tuple sizes. The crate [anon_enum](https://docs.rs/anon_enum/1.1.0/anon_enum/) exists but if we choose to use this pattern, it's probably better to have our own type for greater flexibility and avoid another dependency.

## Full `derive(EditoastError)` spec

The macro supports enums, named structs and tuple structs (fixme correct naming).

````rust
#[derive(thiserror::Error)] // not an EditoastError
#[error("wrong string: {0}")]
struct WrongString(String);

#[derive(EditoastError, thiserror::Error)]
#[error("wrong int: {value}")]
#[editoast_error(user, name = "InvalidInt")]
struct WrongInt { value: i64 };

#[derive(EditoastError, thiserror::Error)]
#[error("my error type")]
#[editoast_error(context)]
enum MyError {
// error_type = "MyError::InvalidString"
// context = { expected_format: string }
// #[editoast_error(internal)] by default
InvalidString { #[from] source: WrongString, expected_format: String },

// error_type = "MyError::InvalidInt"
// context = {} (xyz is skipped as we forward the editoast error)
WrongInt { #[from] #[editoast_error] source: WrongInt, xyz: String }

// error_type = "MyError::Bad"
// context = { "0": string, "1": number }
#[error("user did a bad with {0} and {1}")]
#[editoast_error(user, name = "Bad")]
Oops(String, i64)
}
````

### Providing `context`

Only `4xx` errors require context since these are the errors the frontend may use to provide the user some form of recovery. Context is computed just before the error is serialized in `axum`'s error handler.
leovalais marked this conversation as resolved.
Show resolved Hide resolved

`derive(EditoastError)` provides a few ways to set it.

````rust
#[derive(Debug, thiserror::Error, EditoastError)]
enum Error {
#[editoast_error(user, context)]
No { because: String }, // { }
leovalais marked this conversation as resolved.
Show resolved Hide resolved

#[editoast_error(user, context)]
Nein { reasons: Vec<String> }, // { "reasons": [string] }

// explicitely builds the dictionnary (*)
leovalais marked this conversation as resolved.
Show resolved Hide resolved
#[editoast_error(user, context(
reason = "format!(\"because {reason}\")",
recovery = "format!(\"do this instead {recovery}\")"
))]
QueNo { reason: String, recovery: String },
}

// with a provider function
#[derive(Debug, thiserror::Error, EditoastError)]
#[editoast_error(context_with = context_provider)]
enum Error {
Nao(String),
Nee(String, u64)
}

fn context_provider(error: Error) -> HashMap<String, serde_json::Value> {
todo!()
}
````

`(*)`: only if the mapping can be collected easily using `darling`.

### Incident reports

For `internal` errors that won't contain meaningful information for the end user, we substitute the error by:

```json
{
"error_type": "InternalError",
"message": "<a meaningful message>",
"status": 500,
"context": {},
"incident": "<uuid>"
}
```

In order to be able to find and investigate the error later on, we associate to each `5xx` error a unique `incident` identifier. At first we'll just log the incident with:
leovalais marked this conversation as resolved.
Show resolved Hide resolved

- the error message
- the error `Debug` representation
- the backtrace(s), if any

The log entry will be persisted in datadog/jaeger so it's probably good enough at first.

It's useful in development to have the real error shown in the interface instead of just "Internal error". We can set an environment variable `OSRD_DEV=1` to avoid replacing the error in the `axum` handler.
Loading