Skip to content

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:

  1. StructuredException subclasses from vyuh_errors — typed error shapes for each domain.
  2. ErrorCodesDescriptor — feature-side declaration of error codes (code → status + default message).
  3. 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:

dart
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

dart
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
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:

dart
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:

dart
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

dart
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:

dart
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:

dart
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