Modern .NET Development
Error Handling in ASP.NET Core APIs That Clients Can Rely On
A useful API error does more than describe failure. It tells a client whether to correct the request, authenticate, stop, reconcile uncertain state or retry without making matters worse.
Design errors around the client's next action
Most API error handling is designed from the server outward: catch an exception, select a status code and write a message. Clients experience failure in the opposite direction. They need to know whether a person can correct the request, credentials must be renewed, current state must be reloaded, a retry is safe, or an operator must investigate.
That recovery decision should drive the error taxonomy. A malformed payload, failed business rule, stale version, throttled client and unavailable dependency are not variations of the same error. Returning 400 for all of them makes the transport look tidy while moving uncertainty into every consumer.
The API contract must cover negative outcomes as deliberately as successful representations. Status, stable code, safe detail, correlation context and retry guidance form one contract. An exception is an implementation event; it is not automatically a public error model.
A failure taxonomy that supports recovery
| Failure | Typical status | Client action |
|---|---|---|
| Malformed or invalid request | 400, or documented 422 convention | Correct specific input; do not retry unchanged |
| Missing or invalid authentication | 401 | Authenticate or renew credentials |
| Authenticated but not authorised | 403 | Stop or request access |
| Resource absent or deliberately concealed | 404 | Correct identifier or stop |
| Conflict with current state | 409 | Reload, reconcile or choose another value |
| Precondition failed | 412 | Refresh the representation before writing |
| Rate limit reached | 429 | Wait according to policy, then retry |
| Transient server or dependency failure | 500, 502, 503 or 504 | Retry only when operation semantics allow it |
This is not a demand for dozens of status codes. A small vocabulary applied consistently is enough. The important distinction is recoverability. A client should never infer retry safety from a vague message or from the fact that the server returned a 5xx response.
Problem Details is the envelope, not the taxonomy
ASP.NET Core can produce Problem Details responses centrally, and AddProblemDetails integrates with the framework's exception and status-code handling. That standardises fields such as type, title, status, detail and instance. It does not decide which failures exist or what a client may safely do next.
Extend the envelope with a stable code and trace identifier. Use a type URI as durable documentation if the organisation can maintain it. For validation, return a bounded collection of errors containing a machine-readable code and a field path where applicable. Not every error belongs to a field: “order can no longer be cancelled” concerns the operation and current state.
{
"type": "https://api.example.com/problems/order-conflict",
"title": "The order has changed",
"status": 409,
"code": "order.version_conflict",
"detail": "Reload the order before submitting further changes.",
"traceId": "00-b7f3..."
}
Titles and details are for people and may be reworded or localised. A generated client should branch on code, not parse English. Once published, codes are part of the compatibility surface. Renaming an internal exception must not rename a public error.
Catch exceptions once, but do not map all exceptions alike
Exception middleware or an IExceptionHandler is the right final boundary for unexpected failures and for translating deliberately typed application exceptions. Repeated try/catch blocks in controllers create inconsistent responses and often log the same exception several times.
Do not turn the global handler into a dictionary of every concrete infrastructure exception in the system. Translate known failures close to the boundary that understands them: a database uniqueness violation can become a domain conflict in the persistence adapter; a downstream 404 may mean “supplier unavailable” rather than “our route does not exist.” The HTTP boundary then maps stable application outcomes.
Unexpected exceptions should normally become a generic 500 response and be logged once with their stack and trace context. Returning exception.Message leaks implementation detail and creates an unstable client contract. It can reveal SQL, file paths, dependency addresses, personal data or security assumptions.
Cancellation deserves special treatment. A request aborted by the caller is not necessarily a server defect and should not page an operator. It may still leave committed work behind. Cancellation stops waiting; it does not roll back a payment, message or database transaction that already completed.
A timeout does not tell you whether the operation happened
When an API times out before sending a response, the client knows only that it did not receive an answer. The server may have failed before starting, committed the write and lost the response, or delegated work that remains in progress. Retrying a non-idempotent operation can duplicate the business effect.
This is why “retry all 5xx responses” is unsafe. GET and idempotent updates can often be retried with bounded backoff. Payments, bookings and command submissions need an idempotency key or a client-selected operation identifier. The API must persist that identity with the outcome so a repeated request retrieves the original result rather than executing again.
Long-running work should return 202 with a durable status resource, not keep an HTTP request open indefinitely. The status needs meaningful terminal states, failure codes and retention behaviour. “Accepted” means responsibility has transferred; it must not mean “the server put something in memory and hopes the process survives.”
Validation errors are expected contract outcomes
Malformed JSON and conversion failures belong to input formatting and model binding. Request validation handles command coherence. Domain rules protect valid state transitions. Authorisation and concurrency are separate again. Flattening these into one validation response loses security and recovery meaning.
An authenticated caller without access should receive the chosen 403 or concealed 404 behaviour, not “accountId is invalid.” A stale write should receive a conflict or failed-precondition response, not a field error. A database outage is not evidence that the submitted customer number is wrong.
Field paths should match the wire contract, including array positions, rather than C# property names that differ under JSON naming policies. Limit both request complexity and the number of returned errors. Reporting ten thousand repeated item failures consumes resources and provides little value to a person or client.
404 and 403 can be a deliberate security choice
Returning 404 for a resource that exists but is not visible can prevent enumeration. Returning 403 can be useful when the caller is allowed to know the resource exists and needs to request access. Neither is universally correct; choose per resource sensitivity and document the policy internally.
Whichever response is selected, avoid timing and detail differences that disclose the hidden branch. Resource-based authorisation should run against tenant-scoped data. Do not first load a resource globally, return rich validation about its state, and only later discover that it belongs to another tenant.
Authentication failures should preserve 401 semantics and an appropriate challenge. An authenticated identity that lacks authority is not “unauthenticated.” Clients often respond to 401 by signing in again; returning it for insufficient permission creates login loops without changing access.
Stable codes need governance
Error codes tend to begin as string constants scattered through handlers. Six months later, three spellings describe the same condition and one code has acquired different meanings in two endpoints. Treat the catalogue as part of API design. Give codes an owner, definition, expected status and recovery guidance.
Use domain-oriented names such as invoice.already_paid, not implementation names such as SqlException.2601. Avoid encoding HTTP status into the code because the same domain reason may be presented differently at another boundary. Do not create a unique code for every occurrence; clients need stable categories at the granularity of a useful decision.
Removing or changing a code is a breaking behavioural change even when the JSON schema remains identical. Include errors in OpenAPI examples and consumer contract tests. Generated documentation cannot discover every exception path automatically.
Correlation is not the same as one custom header
Distributed tracing already provides trace and span identifiers that can flow through HTTP and messaging. Prefer standards-based trace context and include a trace identifier in safe error responses so support can connect a report to telemetry. If the business also needs an order or job identifier, record it separately rather than overloading a correlation ID with several meanings.
Validate inbound identifiers before logging or propagating them. Allowing arbitrary unbounded values creates log injection and storage problems. Do not use a correlation identifier as a secret or evidence of authority; it is an observability handle.
A trace can show where a request failed, but async work may outlive that request. Carry causation and business operation identifiers into messages so an operator can follow the eventual outcome. A copied trace ID alone cannot express retries, fan-out or a workflow that lasts days.
Log once, at the level that matches the outcome
Expected validation and conflict responses are not application errors merely because their status begins with four. Logging each as an exception produces noise and trains teams to ignore dashboards. Record them as structured events or metrics when their rate matters. A sudden increase in one error code may reveal a bad client deployment or changed business rule.
Unexpected exceptions should be logged once at the handling boundary with the operation, route template, trace, tenant or subject identifier where appropriate, and dependency context. Never log bearer tokens, secrets or whole request bodies by default. Redaction after collection is weaker than not collecting sensitive data.
Avoid high-cardinality metric labels such as raw URLs, exception messages, email addresses and resource identifiers. Use route templates, exception categories and stable error codes. Preserve detailed identifiers in access-controlled structured logs only when necessary for investigation.
Partial failure needs a business outcome, not a clever status
An endpoint that updates a database, uploads a file and calls another service can fail after some effects complete. No HTTP code can make those actions atomic. Decide which effect is authoritative and how incomplete work is detected and repaired. Use local transactions where they apply, an outbox for reliable event publication, and reconciliation for external side effects.
Multi-status-style responses can be appropriate for deliberate batch operations where each item is independent. They are not a substitute for defining atomicity. State whether the batch is all-or-nothing, best effort or asynchronously processed. Clients need item identifiers and stable outcomes so retrying failed items does not repeat successful ones.
When the server cannot determine the final outcome, say so through a durable operation status rather than claiming failure. “Unknown” is uncomfortable but more accurate than inviting a duplicate command.
Test the failures clients will build around
- Assert the complete Problem Details media type, status, code and field paths through the real HTTP pipeline.
- Verify unexpected exceptions never expose stack traces or internal messages.
- Test wrong-tenant and unauthorised resources for the intended non-disclosure behaviour.
- Simulate a lost response after commit and prove an idempotency key prevents duplication.
- Exercise concurrency conflicts against the real persistence mechanism.
- Verify cancellation and dependency timeout are classified differently from invalid input.
- Contract-test published error codes with important consumers.
- Confirm traces connect gateway, API, dependency and asynchronous work.
Do not test only the mapping function. Middleware ordering, content negotiation, framework-generated validation and responses started before an exception can all change what reaches the wire.
A dependable error contract
A production-ready API has a small documented failure taxonomy, one consistent Problem Details envelope and stable codes owned like any other contract. It separates expected rejections from unexpected defects, protects sensitive information, and gives clients explicit recovery semantics.
The deeper test is uncertainty. If a response is lost, can the client discover what happened? If a dependency times out, is retry safe? If two users write concurrently, is newer work protected? If support receives a trace identifier, can an operator follow the work without searching for credentials or personal data? Error handling is complete only when those questions have designed answers.
Related C3 Software guides
Frequently asked questions
Should an ASP.NET Core API expose exception messages?
No. Return a stable safe error contract and log the exception at the internal handling boundary. Exception messages change with implementation and may expose sensitive detail.
Should clients retry every 500 response?
No. Retry only transient failures and only when the operation is safe to repeat or protected by idempotency. A timeout or 500 does not prove that a write did not happen.
What should an API include in Problem Details?
Include the standard fields appropriate to the failure plus a stable machine-readable code and trace identifier. Validation responses may add bounded field errors with wire-format paths.
Should expected validation errors be logged as exceptions?
Usually not. Capture structured counts and context when useful, but reserve exception-level logging and alerting for unexpected defects or operational failures.
Build API failures clients can recover from
C3 Software Limited has built and supported business software in the UK since 2001. If inconsistent errors or uncertain retries are making an integration fragile, talk to C3 Software about an API contract review or implementation plan.