Skip to content

Error Handling Design

Purpose

Define a structured, translatable, classifiable error system that replaces the current ad-hoc mix of anonymous objects, raw exception messages, and empty NotFound() responses across the API surface.

Every API error response must tell the caller: what went wrong, whose fault it is, what category it falls into, and a stable code the client can switch on — without leaking implementation details.

Current State (Problems)

Problem Evidence
Inconsistent response shapes Some endpoints return ApiErrorDto { error }, some return new { error = "..." }, some return empty NotFound() with no body
No error codes Callers cannot programmatically distinguish "hero not found" from "class not found" — they parse English strings
No fault classification A 400 from malformed JSON and a 400 from a business rule violation look identical to the caller
Raw exception messages leak to Admin UI catch (Exception ex) { _message = ex.Message; } displays internal details
No translation surface All error text is hardcoded English in controller methods
Unhandled auth exceptions UnauthorizedAccessException thrown in controllers propagates to middleware as a 500 instead of a 401
No validation aggregation Endpoints return the first validation failure and stop — the client has to fix-and-retry one field at a time
No correlation Error responses don't include the CorrelationId that telemetry already tracks, making incident lookups harder

Design

1. Structured Error Response Envelope

Every non-2xx API response returns this shape:

{
  // Stable machine-readable code. Clients switch on this.
  "code": "HERO_NOT_FOUND",

  // Human-readable message. Translated per Accept-Language.
  "message": "The requested hero does not exist.",

  // Whose fault: "Client" or "System"
  "fault": "Client",

  // Functional category for grouping and filtering.
  "category": "NotFound",

  // Per-request correlation for incident lookups. Always present.
  "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",

  // Optional: field-level validation details (only on validation errors).
  "details": [
    { "field": "Name", "issue": "Name is required." },
    { "field": "BaseHp", "issue": "BaseHp must be greater than zero." }
  ]
}

2. Fault Classification

Every error is either a Client fault or a System fault.

Fault Meaning HTTP range Example
Client The caller sent something wrong. Fix the request and retry. 4xx Missing field, invalid ID, business rule violation
System The server failed. The caller did nothing wrong. 5xx Database down, unhandled exception, timeout

3. Error Categories

Categories group errors by domain concern. The same category can appear in both fault types.

Category Description Examples
Validation Request payload fails structural or business-rule checks Missing name, negative HP, inverted tier range
NotFound The referenced entity does not exist Hero, card, quest not found
Auth Authentication or authorization failure Bad credentials, expired token, missing role
Conflict The operation conflicts with current state Duplicate registration, race condition on publish
Lifecycle Content lifecycle rule violation Saving a published entity, promoting without staged
RateLimit Too many requests Login brute-force, bulk API abuse
Infrastructure Server-side platform failure Database unavailable, Redis timeout
Internal Unclassified server error Unhandled exception fallback

4. Error Code Registry

Each error has a unique string code. Codes are uppercase, underscore-separated, prefixed by domain.

The full registry lives in a single C# source file (ErrorCodes.cs) so codes are discoverable and cannot drift between API and clients.

Auth Codes

Code Fault Category HTTP Default Message
AUTH_INVALID_CREDENTIALS Client Auth 401 Invalid username or password.
AUTH_TOKEN_EXPIRED Client Auth 401 Your session has expired. Please log in again.
AUTH_TOKEN_INVALID Client Auth 401 The authentication token is invalid.
AUTH_INSUFFICIENT_ROLE Client Auth 403 You do not have permission to perform this action.
AUTH_REGISTRATION_CONFLICT Client Conflict 409 An account with that username already exists.
AUTH_REFRESH_FAILED Client Auth 401 Token refresh failed. Please log in again.
AUTH_USER_NOT_FOUND Client Auth 401 Authentication failed.

Validation Codes

Code Fault Category HTTP Default Message
VALIDATION_REQUIRED_FIELD Client Validation 400 A required field is missing.
VALIDATION_INVALID_VALUE Client Validation 400 A field value is invalid.
VALIDATION_RANGE_EXCEEDED Client Validation 400 A numeric value is out of the allowed range.
VALIDATION_STRING_TOO_LONG Client Validation 400 A text field exceeds the maximum length.
VALIDATION_MALFORMED_JSON Client Validation 400 The request body is not valid JSON.
VALIDATION_INVALID_ENUM Client Validation 400 A field value is not a recognized option.

Entity Codes

Code Fault Category HTTP Default Message
ENTITY_NOT_FOUND Client NotFound 404 The requested resource does not exist.
ENTITY_SAVE_FAILED System Internal 500 Failed to save the entity.

Hero Codes

Code Fault Category HTTP Default Message
HERO_NOT_FOUND Client NotFound 404 The requested hero does not exist.
HERO_LIMIT_REACHED Client Validation 400 You have reached the maximum number of heroes.
HERO_INVALID_CLASS Client Validation 400 The selected class does not exist.
HERO_INVALID_FACTION Client Validation 400 The selected faction does not exist.
HERO_NAME_REQUIRED Client Validation 400 A hero name is required.
HERO_INVALID_LEVEL Client Validation 400 Hero level must be at least 1.
HERO_INVALID_XP Client Validation 400 Hero XP cannot be negative.

Run Codes

Code Fault Category HTTP Default Message
RUN_NOT_FOUND Client NotFound 404 The requested run does not exist.
RUN_HERO_REQUIRED Client Validation 400 A hero must be selected to start a run.
RUN_CLASS_REQUIRED Client Validation 400 A class must be selected to start a run.
RUN_FACTION_REQUIRED Client Validation 400 A faction must be selected to start a run.
RUN_ALREADY_ACTIVE Client Conflict 409 This hero already has an active run.

Lifecycle Codes

Code Fault Category HTTP Default Message
LIFECYCLE_IMMUTABLE Client Lifecycle 400 Published content cannot be modified. Create a new version instead.
LIFECYCLE_INVALID_TRANSITION Client Lifecycle 400 This status transition is not allowed.
LIFECYCLE_STAGED_EXISTS Client Lifecycle 409 A staged version already exists for this lineage.
LIFECYCLE_NOTHING_STAGED Client Lifecycle 400 No staged content is available to publish.
LIFECYCLE_ENTITY_NOT_FOUND Client NotFound 404 The content entity does not exist.

Content Codes

Code Fault Category HTTP Default Message
CONTENT_SNAPSHOT_NOT_FOUND Client NotFound 404 No published content snapshot was found.
CONTENT_ASSET_RANGE_EXHAUSTED System Internal 500 The AssetId range for this entity type is full.

System Codes

Code Fault Category HTTP Default Message
SYSTEM_DATABASE_ERROR System Infrastructure 503 A database error occurred. Please try again later.
SYSTEM_CACHE_ERROR System Infrastructure 503 A cache error occurred. Please try again later.
SYSTEM_INTERNAL_ERROR System Internal 500 An unexpected error occurred.
SYSTEM_REQUEST_TIMEOUT System Infrastructure 504 The request timed out.
SYSTEM_RATE_LIMITED Client RateLimit 429 Too many requests. Please wait before trying again.

5. Translation Architecture

Error messages are resource-driven, not hardcoded in controller code.

src/EchoSpire.Contracts/
  Errors/
    ErrorCodes.cs              ← string constants + metadata registry
    IErrorMessageProvider.cs   ← abstraction for message resolution
    ErrorMessages.resx         ← English (default) resource file
    ErrorMessages.es.resx      ← Spanish (future)
    ErrorMessages.fr.resx      ← French (future)

How it works:

  1. ErrorCodes.cs defines each code as a const string and a static registry mapping code → fault, category, HTTP status, and resx key.
  2. IErrorMessageProvider.Resolve(string code, params object[] args) loads the message from the .resx file for the current Accept-Language, with optional string-format arguments (e.g., field names, limits).
  3. The API reads Accept-Language from the request header and sets CultureInfo.CurrentUICulture in middleware. The resource manager picks the right .resx file automatically — this is built-in .NET behavior, no custom plumbing needed.
  4. Fallback: if no translation exists for the requested language, the default English .resx is used.

Resource file entry format:

<!-- ErrorMessages.resx -->
<data name="HERO_NOT_FOUND" xml:space="preserve">
  <value>The requested hero does not exist.</value>
</data>
<data name="VALIDATION_REQUIRED_FIELD" xml:space="preserve">
  <value>{0} is required.</value>
</data>

The {0} placeholder lets messages include context: Resolve("VALIDATION_REQUIRED_FIELD", "Name") → "Name is required."

6. C# Implementation Shape

Contracts Layer (shared across API, Admin, WPF, Unity)

// ErrorCodes.cs — lives in EchoSpire.Contracts
public static class ErrorCodes
{
    // Auth
    public const string AuthInvalidCredentials  = "AUTH_INVALID_CREDENTIALS";
    public const string AuthTokenExpired         = "AUTH_TOKEN_EXPIRED";
    public const string AuthInsufficientRole     = "AUTH_INSUFFICIENT_ROLE";
    public const string AuthRegistrationConflict = "AUTH_REGISTRATION_CONFLICT";
    // ... all codes listed above

    // Registry: code → metadata
    public static readonly IReadOnlyDictionary<string, ErrorMeta> Registry = new Dictionary<string, ErrorMeta>
    {
        [AuthInvalidCredentials]  = new("Client", "Auth", 401),
        [AuthTokenExpired]        = new("Client", "Auth", 401),
        [AuthInsufficientRole]    = new("Client", "Auth", 403),
        [AuthRegistrationConflict]= new("Client", "Conflict", 409),
        // ...
    };
}

public sealed record ErrorMeta(string Fault, string Category, int HttpStatus);

API Error Response DTO (replaces ApiErrorDto)

// Replaces the existing ApiErrorDto
public sealed class ApiError
{
    public required string Code          { get; init; }
    public required string Message       { get; init; }
    public required string Fault         { get; init; }  // "Client" | "System"
    public required string Category      { get; init; }
    public required string CorrelationId { get; init; }
    public List<FieldError>? Details     { get; init; }
}

public sealed class FieldError
{
    public required string Field { get; init; }
    public required string Issue { get; init; }
}

Controller Helpers (API layer)

// Extension method or base controller helper
public static class ApiErrorExtensions
{
    public static IActionResult ApiError(
        this ControllerBase controller,
        string code,
        IErrorMessageProvider messages,
        params object[] args)
    {
        var meta = ErrorCodes.Registry[code];
        var correlationId = controller.HttpContext.Items["CorrelationId"]?.ToString() ?? "";

        return new ObjectResult(new ApiError
        {
            Code          = code,
            Message       = messages.Resolve(code, args),
            Fault         = meta.Fault,
            Category      = meta.Category,
            CorrelationId = correlationId,
        })
        { StatusCode = meta.HttpStatus };
    }

    // Overload for validation errors with field details
    public static IActionResult ApiValidationError(
        this ControllerBase controller,
        string code,
        IErrorMessageProvider messages,
        List<FieldError> details)
    {
        var meta = ErrorCodes.Registry[code];
        var correlationId = controller.HttpContext.Items["CorrelationId"]?.ToString() ?? "";

        return new ObjectResult(new ApiError
        {
            Code          = code,
            Message       = messages.Resolve(code),
            Fault         = meta.Fault,
            Category      = meta.Category,
            CorrelationId = correlationId,
            Details       = details,
        })
        { StatusCode = meta.HttpStatus };
    }
}

Usage in Controllers (before → after)

// ── BEFORE ──────────────────────────────────
return NotFound();                                          // no body
return BadRequest(new { error = "Hero not found" });        // anonymous object
return Conflict(new ApiErrorDto { Error = "Username..." }); // old DTO

// ── AFTER ───────────────────────────────────
return this.ApiError(ErrorCodes.HeroNotFound, _messages);
return this.ApiError(ErrorCodes.AuthRegistrationConflict, _messages);
return this.ApiValidationError(
    ErrorCodes.ValidationRequiredField,
    _messages,
    [new FieldError { Field = "Name", Issue = _messages.Resolve(ErrorCodes.ValidationRequiredField, "Name") }]);

7. Global Exception Middleware (enhanced)

The existing TelemetryMiddleware catch block is extended to produce the envelope:

catch (Exception ex) when (ex is not OperationCanceledException)
{
    // Map well-known exception types to error codes
    var (code, statusCode) = ex switch
    {
        UnauthorizedAccessException => (ErrorCodes.AuthTokenInvalid, 401),
        InvalidOperationException   => (ErrorCodes.LifecycleInvalidTransition, 400),
        _                           => (ErrorCodes.SystemInternalError, 500),
    };

    // Record telemetry (existing behavior)
    // ...

    context.Response.StatusCode = statusCode;
    context.Response.ContentType = "application/json";
    await context.Response.WriteAsJsonAsync(new ApiError
    {
        Code          = code,
        Message       = _messages.Resolve(code),
        Fault         = ErrorCodes.Registry[code].Fault,
        Category      = ErrorCodes.Registry[code].Category,
        CorrelationId = correlationId,
    });
}

This ensures that even unhandled exceptions produce the structured envelope rather than an HTML developer exception page or an empty 500.

8. Admin Client Error Handling (consuming side)

The Admin app deserializes ApiError and renders it appropriately:

// EchoSpireApiClient — replaces EnsureSuccessStatusCode()
if (!response.IsSuccessStatusCode)
{
    var apiError = await response.Content.ReadFromJsonAsync<ApiError>();
    throw new ApiException(apiError ?? ApiError.Unknown(correlationId));
}
// Razor pages — replaces catch (Exception ex) { _message = ex.Message; }
catch (ApiException ex)
{
    _message = ex.Error.Message;         // translated, safe message
    _messageCode = ex.Error.Code;        // for programmatic handling
    _messageSeverity = ex.Error.Fault == "Client"
        ? Severity.Warning
        : Severity.Error;
}

This means: - Client faults (validation, not found) show as warnings — the user can fix them. - System faults (database down) show as errors — nothing the user can do. - No raw exception messages ever reach the UI.

9. WPF / Unity Client Error Handling

Since ErrorCodes and ApiError live in Contracts (shared), game clients get the same structured errors and can:

  • Switch on Code to show context-specific UI (e.g., a login-specific failure dialog for AUTH_INVALID_CREDENTIALS)
  • Use Fault to decide whether to retry (System) or prompt user correction (Client)
  • Display Message in a toast or dialog — already translated server-side
  • Log CorrelationId in telemetry for support requests

10. Implementation Sequence

Step Scope What
1 Contracts Create ErrorCodes.cs, ErrorMeta, ApiError, FieldError, IErrorMessageProvider
2 Contracts Create ErrorMessages.resx with all English messages
3 API Create ResourceErrorMessageProvider implementing IErrorMessageProvider using ResourceManager
4 API Add Accept-LanguageCultureInfo middleware
5 API Add ApiError extension methods to a base/helper
6 API Update TelemetryMiddleware to produce ApiError envelope on unhandled exceptions
7 API Convert all controllers to use this.ApiError(...) and this.ApiValidationError(...)
8 Admin Add ApiException class, update EchoSpireApiClient to deserialize ApiError
9 Admin Update all Razor pages to use ApiException with fault-aware severity
10 Tests Add tests for error codes, message resolution, controller error responses, middleware fallback

11. File Inventory (new and modified)

New files:

File Purpose
src/EchoSpire.Contracts/Errors/ErrorCodes.cs Code constants + metadata registry
src/EchoSpire.Contracts/Errors/ErrorMeta.cs Record type for fault/category/status
src/EchoSpire.Contracts/Errors/ApiError.cs Structured error response DTO (replaces ApiErrorDto)
src/EchoSpire.Contracts/Errors/FieldError.cs Validation detail DTO
src/EchoSpire.Contracts/Errors/IErrorMessageProvider.cs Message resolution abstraction
src/EchoSpire.Contracts/Errors/ErrorMessages.resx English message resources
src/EchoSpire.API/Infrastructure/ResourceErrorMessageProvider.cs .resx-backed message provider
src/EchoSpire.API/Infrastructure/CultureMiddleware.cs Accept-Language → CultureInfo
src/EchoSpire.API/Infrastructure/ApiErrorExtensions.cs Controller helper extensions
src/EchoSpire.Admin/Services/ApiException.cs Typed exception wrapping ApiError

Modified files:

File Change
TelemetryMiddleware.cs Produce ApiError envelope instead of re-throwing
AuthController.cs Replace anonymous objects / ApiErrorDto with this.ApiError(...)
HeroesController.cs Replace inline validation strings with error codes
RunsController.cs Replace inline validation strings with error codes
AdminController.cs Replace InvalidOperationException catch strings with error codes
GameDataController.cs Replace empty NotFound() with this.ApiError(ErrorCodes.EntityNotFound, ...)
StoriesController.cs Replace UnauthorizedAccessException with error code return
TelemetryController.cs Replace anonymous error objects with error codes
HealthController.cs Replace inline error shapes with error codes
EchoSpireApiClient.cs Deserialize ApiError, throw ApiException
All 9 Admin editor pages Replace catch (Exception) with catch (ApiException) and fault-aware display
AdminAuthSession.cs Use ApiException for login/refresh errors
AuthContracts.cs Remove deprecated ApiErrorDto (or mark obsolete)

12. Extensibility Path

  • Adding a new error: Add one const string to ErrorCodes.cs, one entry to the registry dictionary, one entry to ErrorMessages.resx. The middleware, helpers, and clients all pick it up automatically.
  • Adding a language: Add a new ErrorMessages.{culture}.resx file with translated strings. No code changes. .NET ResourceManager resolves the right file based on CurrentUICulture.
  • Adding a new category: Add the category string to the table above. Categories are not an enum — they're convention-driven strings so new ones don't require shared library recompilation.

Design Decisions

Decision Rationale
String codes, not int codes "HERO_NOT_FOUND" is self-documenting in logs, JSON responses, and client code. Integer codes require a lookup table.
Flat code namespace, not nested "HERO_NOT_FOUND" instead of "Hero.NotFound" — simpler to grep, no escaping in resx keys, no ambiguity about separator character.
Server-side translation The API owns the message catalog. Clients display what they receive. This avoids duplicating message files across WPF, Admin, and Unity, and means a server deploy updates all clients' error messages instantly.
Accept-Language header Standard HTTP content negotiation. Clients set it once; the API resolves the right culture. No custom headers or query params.
Category is a string, not an enum Avoids forcing shared library version bumps when a new category is added. The set of categories is stable but open.
CorrelationId in every error Already tracked in telemetry. Including it in the error response lets operators ask users "what was the error code?" and jump straight to the telemetry record.
details array for validation Allows returning all field errors at once instead of stop-on-first. Matches the pattern used by ASP.NET ValidationProblemDetails.
Separate Fault from HTTP status A 400 can be either a validation issue (Client) or a business rule violation (Client). A 503 is always System. The Fault field makes the distinction explicit regardless of status code.

Relationship To Other Docs

Maintenance Rule

When a new API endpoint or error condition is added, add the corresponding error code, registry entry, and resx message in the same PR. Do not ship endpoints that return bare strings or anonymous error objects.