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:
ErrorCodes.csdefines each code as aconst stringand a static registry mapping code → fault, category, HTTP status, and resx key.IErrorMessageProvider.Resolve(string code, params object[] args)loads the message from the.resxfile for the currentAccept-Language, with optional string-format arguments (e.g., field names, limits).- The API reads
Accept-Languagefrom the request header and setsCultureInfo.CurrentUICulturein middleware. The resource manager picks the right.resxfile automatically — this is built-in .NET behavior, no custom plumbing needed. - Fallback: if no translation exists for the requested language, the default English
.resxis 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
Codeto show context-specific UI (e.g., a login-specific failure dialog forAUTH_INVALID_CREDENTIALS) - Use
Faultto decide whether to retry (System) or prompt user correction (Client) - Display
Messagein a toast or dialog — already translated server-side - Log
CorrelationIdin 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-Language → CultureInfo 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 stringtoErrorCodes.cs, one entry to the registry dictionary, one entry toErrorMessages.resx. The middleware, helpers, and clients all pick it up automatically. - Adding a language: Add a new
ErrorMessages.{culture}.resxfile with translated strings. No code changes. .NETResourceManagerresolves the right file based onCurrentUICulture. - 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¶
- technical-architecture.md — system boundaries and API contracts
- next-steps.md — tracks implementation progress
- content-lifecycle-v2.md — lifecycle status transitions that generate
LIFECYCLE_*errors - live-site-ai-agent.md — agent evidence flows that benefit from
CorrelationIdin error responses
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.