LogoRevali

Error Responses

Return a machine-readable error code that survives the hop to the caller

A status code tells the caller that something went wrong. It does not tell them what.

Two different 404s — an unknown user and an unknown organization — look identical from the outside. A service calling you has exactly two options: give up, or match on the human-readable message you happened to write, which was never meant to be an API and will change the first time somebody improves the wording. HttpError closes that gap by carrying a stable code alongside the status, so the caller can branch on the thing that actually differs.

Throwing an HttpError#

HttpError lives in package:revali_core and is re-exported by package:revali_router, so a controller already has it in scope:

routes/controllers/user_controller.dart
import 'package:revali_router/revali_router.dart';

@Controller('users')
class UserController {
  const UserController();

  @Get(':id')
  Future<User> get(@Param() String id) async {
    final user = await userService.find(id);

    if (user == null) {
      throw HttpError.notFound(
        code: 'user_not_found',
        message: 'No user with id $id',
        details: {'id': id},
      );
    }

    return user;
  }
}

Named constructors cover the statuses that come up most often:

ConstructorStatus
HttpError.badRequest400
HttpError.unauthorized401
HttpError.forbidden403
HttpError.notFound404
HttpError.conflict409
HttpError.unprocessable422
HttpError.internal500

For anything else, the unnamed constructor takes statusCode directly:

throw const HttpError(
  statusCode: 429,
  code: 'rate_limited',
  message: 'Too many requests',
  details: {'retryAfter': 30},
);

Every constructor is const, so an error with no interpolated values can be a compile-time constant.

The four fields#

  • statusCode — the HTTP status this failure responds with.
  • code — a stable, machine-readable identifier: user_not_found, not User not found. This is the part callers branch on.
  • message — a human-readable explanation, for logs and developers. It is free to change wording without notice, which is precisely why code exists. Never parse it.
  • details — extra machine-readable context: which field failed validation, how long to wait. It crosses the wire to whoever called you, so keep internals out of it. Defaults to {}.

The wire format#

An uncaught HttpError responds with its own status and a JSON body nested under error:

{
  "error": {
    "code": "user_not_found",
    "message": "No user with id 7",
    "details": {"id": "7"}
  }
}

details is omitted entirely when it is empty, rather than serialized as null or {} — a caller checking for its presence gets a useful answer. The response is application/json, the same as any other map-shaped body.

The nesting is deliberate: it mirrors the {"data": ...} wrapper that successful JSON responses already use, so a caller reads one envelope shape for both outcomes and only has to look at which key is present.

It is opt-in, and it does not override your catchers#

Nothing about the framework's existing plain-text defaults changes. A handler that throws a StateError, or anything else that is not an HttpError, still gets the default response it always got — a 500 with a plain-text body. A caller sees the envelope only for handlers that chose to raise one, so a client reading today's responses keeps working after you adopt this.

The same applies to exception catchers. The envelope is a fallback for an unclaimed error, not an override: the router runs the catcher chain first, and only reaches for toEnvelope() when no catcher handled the exception. An app that registered a catcher for its own HttpError subtype still wins, and can respond with whatever shape it likes.

final class BillingErrorCatcher extends ExceptionCatcher<HttpError> {
  const BillingErrorCatcher();

  @override
  ExceptionCatcherResult<HttpError> catchException(
    HttpError exception,
    Context context,
  ) {
    // This claims the error, so the default envelope never runs for it.
    return ExceptionCatcherResult.handled(
      statusCode: 402,
      body: {'billing': exception.code},
    );
  }
}

Reading it back with revali_client#

The other half of the hop. A generated revali_client throws ServerException for any non-2xx response, and it reads the error envelope when the peer sent one:

try {
  final user = await client.users.get(id: '1');
} on ServerException catch (e) {
  if (e.code == 'user_not_found') {
    // Branch on the code, not on a status shared with every other 404.
    return null;
  }

  rethrow;
}
FieldMeaning
statusCodeThe HTTP status. Always present.
messageThe HTTP reason phrase. Always present.
bodyThe raw response body. Always available.
code The peer's stable error identifier, or null if it sent no envelope.
reason The peer's human-readable message from the envelope, or null.
detailsThe envelope's details map, or null.
isStructured Whether the peer sent an envelope — that is, whether code is non-null.

Note that the envelope's message arrives as reason, not message: ServerException.message was already the HTTP reason phrase before any of this existed, and renaming it would have broken every existing catch.

Parsing is best-effort on purpose#

This code runs against whatever another service actually sent — a plain-text response, an intermediary's HTML error page, a third-party API with its own shape, or a truncated body. None of those are your bug to surface as a parse failure, so a body that is not JSON, or is JSON without a map-shaped error key, leaves code, reason and details null and falls back to statusCode plus the raw body. A malformed body surfaces as the HTTP failure it already is, never as a FormatException thrown from inside the client.

The practical consequence: isStructured is the check worth writing before you trust code. A peer that is not a Revali service — or is one that hasn't adopted HttpError yet — behaves exactly as it did before.

Why there are no generated typed exceptions#

A reasonable next step would be for the client generator to emit one exception class per error a given endpoint can raise, so client.users.get() could be caught as UserNotFoundException and the compiler would check the branches for you. That was deliberately not built.

It would require every handler to declare up front which errors it can raise — an annotation on the method, plus a route contract that carries the declarations through generation to the client. That is a real surface area to design, and one that goes stale the moment a handler throws something it forgot to declare: the generated type would then promise coverage it does not have, which is worse than no promise.

HttpError.code gets most of the value without any of that. It is a string comparison instead of a type check, but it is honest about what the server actually sends, and it does not foreclose the typed version later — the codes are already the thing a generated exception would be named after.

Next Steps#