Error Handling
Vyuh Server uses structured errors end to end. Handlers throw typed exceptions; GlobalMiddleware.errorHandler() formats them into consistent JSON responses with stable error codes, HTTP statuses, and optional details that clients can render inline.
The Pipeline
Three pieces are involved:
StructuredExceptionsubclasses fromvyuh_errors— typed error shapes for each domain.ErrorCodesDescriptor— feature-side declaration of error codes (code → status + default message).GlobalMiddleware.errorHandler()— installed in your global middleware list, catches thrown exceptions and formats them.
Declaring Error Codes
A feature declares its error codes through a descriptor:
FeatureDescriptor(
name: 'billing',
descriptors: [
ErrorCodesDescriptor([
ErrorCode(
code: 'billing.invoice.locked',
httpStatus: 409,
defaultMessage: 'Invoice is locked; cannot modify.',
),
ErrorCode(
code: 'billing.payment.declined',
httpStatus: 402,
defaultMessage: 'Payment was declined.',
),
ErrorCode(
code: 'billing.subscription.expired',
httpStatus: 410,
defaultMessage: 'Subscription expired.',
),
]),
],
);The ErrorCodesPlugin (included with the framework) claims the descriptor and registers each code in vyuh.errors.
Throwing in a Handler
Future<Response> updateInvoice(Request req) async {
final id = req.pathParameters['id']!;
final invoice = await loadInvoice(id);
if (invoice.status == 'locked') {
throw StructuredException(
code: 'billing.invoice.locked',
message: 'Invoice #${invoice.number} is locked',
details: [
ErrorDetail.text(
title: 'Why',
body: 'Invoices become locked after payment to preserve audit trail.',
),
],
);
}
// ... actual update
}The middleware resolves billing.invoice.locked in vyuh.errors, finds httpStatus: 409, and returns:
HTTP/1.1 409 Conflict
Content-Type: application/json
X-Request-Id: req_abc123
{
"error": {
"code": "billing.invoice.locked",
"message": "Invoice #INV-2024-0042 is locked",
"details": [
{
"kind": "text",
"title": "Why",
"body": "Invoices become locked after payment to preserve audit trail."
}
]
}
}The same shape every time. No per-endpoint custom envelopes.
Domain-Specific Subclasses
For richer typed details, subclass StructuredException:
class InvoiceLockedException extends StructuredException {
const InvoiceLockedException({
required this.invoiceId,
required this.lockedAt,
}) : super(code: 'billing.invoice.locked');
final String invoiceId;
final DateTime lockedAt;
@override
Map<String, Object?> toJson() => {
'code': code,
'invoice_id': invoiceId,
'locked_at': lockedAt.toIso8601String(),
};
}Then throw:
throw InvoiceLockedException(
invoiceId: invoice.id,
lockedAt: invoice.paidAt!,
);The client receives typed fields it can render inline (e.g., showing the lock timestamp) without parsing free-text messages.
Wiring the Middleware
await runtime.start(
port: 8080,
middleware: [
GlobalMiddleware.cors(),
GlobalMiddleware.errorHandler(), // catches structured exceptions
GlobalMiddleware.requestLogger(),
],
);The default error handler reads codes from vyuh.errors. Pass a custom formatter when you need to:
GlobalMiddleware.errorHandler(
errorHandler: (exception, request) {
// Custom JSON envelope, custom headers, custom logging
return Response(...);
},
);Used sparingly — the default is right for nearly every service.
Validation Errors
For entity CRUD, EntityValidationException is the canonical shape:
validateCreate: (payload) {
final name = payload['name'] as String?;
if (name == null || name.isEmpty) {
throw EntityValidationException(
field: 'name',
reason: 'Name is required',
);
}
if (name.length > 100) {
throw EntityValidationException(
field: 'name',
reason: 'Name must be 100 characters or fewer',
);
}
return payload;
},The framework formats these as 400 Bad Request with a field in the response so clients can highlight the right input.
Logging
The error handler logs every thrown exception via vyuh.telemetry.event('error.handled', attrs: {...}). With OtelTelemetryPlugin, this becomes a span event you can alert on:
sum by (code) (rate(otel_events{name="error.handled"}[5m]))The middleware also sets span.recordException(...) on the active request span (when tracingMiddleware is installed). Stack traces flow into OTel without per-handler wiring.
Where to Go Next
- Middleware & Context — where the error handler sits
- Descriptors —
ErrorCodesDescriptorin detail vyuh.errors— the registry interface