Error Handling Best Practices for Reliable Applications

Reliable applications are not applications where errors never happen.

Network calls time out. Users submit invalid data. Files disappear. Databases reject queries. Dependencies become unavailable, and code eventually encounters conditions its developers did not anticipate.

Reliability depends on what the application does next.

A useful error-handling design should answer three questions clearly:

What failed? What should happen now? What information needs to survive so the failure can be understood later?

If those decisions are left to scattered try/catch blocks, generic 500 responses and whatever happens to appear in the logs, relatively small failures can become confusing user experiences and difficult production incidents.

For Levelworks, this primarily affects the Error Free and Explainable & Debuggable dimensions of App Health. Errors will still occur, but healthy applications contain them, report them accurately and preserve enough context for teams to act.

Distinguish expected errors from unexpected failures

Not every unsuccessful operation is an application defect.

These are different situations:

  • a user enters an invalid email address;
  • a requested record no longer exists;
  • a customer lacks permission to perform an action;
  • a payment provider times out;
  • application code dereferences an unexpected null value.

Treating all five as the same generic error makes both user behaviour and operational monitoring worse.

Expected conditions should normally have deliberate handling and clear outcomes. Unexpected exceptions should surface as genuine failures that engineering can investigate.

OpenTelemetry's current guidance makes a similar distinction: whether an operation should be treated as an error depends on the context, and an unsuccessful status does not automatically mean the application itself malfunctioned.

A useful classification is:

Error type

Typical handling

Invalid user input

Explain what needs correcting

Business-rule rejection

Explain why the action cannot proceed

Authorization failure

Deny safely without exposing sensitive detail

Temporary dependency problem

Fail or retry according to defined policy

Unexpected application exception

Contain, log and investigate

That classification gives the rest of the error-handling strategy a meaningful starting point.

Do not catch an error unless you can do something useful with it

Broad exception handling can make an application appear stable while hiding real defects.

For example:

try:

    process_order()

catch:

    return success

The application did not become reliable because it avoided crashing. It simply lost the information that the order failed.

Microsoft's .NET exception guidance recommends catching exceptions when the application can recover, release resources or add useful handling, while noting that undefined behaviour can be less reliable than allowing a serious error to fail clearly.

A catch block should generally have a purpose:

  • convert a low-level error into a meaningful domain error;
  • retry a known transient failure;
  • release a resource;
  • add useful diagnostic context;
  • return a safe response;
  • trigger fallback behaviour.

If it can do none of those, swallowing the exception often makes diagnosis harder.

Preserve the original failure context

One common maintenance problem is an error that loses useful information as it moves through application layers.

A database throws a timeout.

The repository converts it to "Database error".

The service changes that to "Operation failed".

The API finally returns "Something went wrong".

The user-facing message may appropriately remain generic, but engineering now needs access to the original cause.

Preserve meaningful context such as:

  • exception type;
  • original cause;
  • affected operation;
  • correlation or request ID;
  • relevant resource identifier;
  • dependency involved;
  • application version.

OpenTelemetry recommends consistently recording error type and exception information and correlating exception records with the operation in which they occurred.

[Internal Link: “How Better Logging Makes Apps Easier to Maintain”]

Return errors that clients can actually understand

For APIs, error responses are part of the interface.

Returning 200 OK with:

{"success": false}

for every failure forces clients to invent their own interpretation of whether an HTTP request actually succeeded.

HTTP already provides semantics for this. RFC 9110 distinguishes client errors in the 4xx range from server failures in the 5xx range, allowing clients and infrastructure to reason about the result appropriately.

Where applications need more detail, RFC 9457 Problem Details for HTTP APIs defines a standard structure for machine-readable error information instead of requiring every API to invent a different format.

A useful API error should tell the client enough to decide what to do next without exposing unnecessary internal detail.

For example, distinguish between:

  • invalid request;
  • authentication required;
  • permission denied;
  • resource not found;
  • conflict with existing state;
  • temporary dependency failure;
  • unexpected server error.

Clients can then respond correctly instead of treating every failure as retryable.

Do not expose internal errors directly to users

Detailed errors are useful internally and potentially dangerous externally.

A raw exception might reveal:

  • database structure;
  • file paths;
  • framework versions;
  • internal service names;
  • SQL;
  • stack traces;
  • implementation details.

OWASP's Error Handling Cheat Sheet warns that poorly handled errors can expose technical information useful to attackers during reconnaissance.

Separate user information from diagnostic information.

A user might see:

We couldn't complete your request. Please try again later. Reference: 7AB42.

Internally, the same reference can connect to the full exception, service name, request trace and relevant context.

This gives the user something actionable without turning the error screen into a map of the application's internals.

Retry only errors that are genuinely retryable

A retry is part of error handling only when another attempt has a realistic chance of producing a different result.

Retrying a brief network timeout may make sense.

Retrying invalid input does not.

Neither does endlessly retrying a permanent authorization failure.

Classify errors into at least:

Retryable: temporary network failure, transient dependency overload.

Non-retryable: malformed input, permission denied, invalid business state.

Unknown: unexpected condition requiring investigation rather than automatic repetition.

Retries also need limits and backoff so an unhealthy dependency is not flooded with additional requests.

Just as importantly, mutating operations need protection against duplicate side effects. Retrying an ambiguous payment or order submission without idempotency can create a worse failure than the original timeout.

Restore or protect state when an operation fails

An error should not leave the application halfway between two valid states.

Suppose an operation:

  1. creates an order;
  2. reserves inventory;
  3. records payment information.

If the third step fails, the application needs an explicit decision about what happens to the first two.

Depending on the architecture, that may involve a transaction, compensation, durable workflow state or later reconciliation.

Microsoft's exception-handling guidance specifically recommends restoring state when an operation does not complete successfully.

Reliable error handling therefore means more than producing a message.

It protects the correctness of the application after failure.

Log the error once at the level where it becomes meaningful

Logging every exception at every layer creates duplicate noise.

A database client logs the timeout, the repository logs it again, the service logs it again and the API handler logs the same exception once more.

One failure now looks like four.

Instead, choose the layer where the application has enough context to describe the problem meaningfully, and correlate lower-level telemetry where necessary.

Useful error records should make questions like these easier to answer:

  • Which operation failed?
  • Which customer or request was affected?
  • Which dependency was involved?
  • Was the failure expected?
  • Did the application retry?
  • Did the final operation succeed or fail?

OpenTelemetry also recommends severity based on the actual impact of the exception rather than treating every exception identically.

[Internal Link: “How to Find the Root Cause of Application Errors Faster”]

Test failure paths deliberately

Error handling often receives less testing than successful workflows.

That is why problems emerge in production such as:

  • fallback code throwing another exception;
  • error pages revealing stack traces;
  • retries duplicating transactions;
  • failed jobs disappearing silently;
  • incorrect HTTP status codes;
  • resources not being released after failure.

Tests should cover representative failure conditions alongside successful ones.

For important workflows, ask:

  • What if the database request fails?
  • What if the dependency times out?
  • What if the user submits invalid state?
  • What if the same request is retried?
  • What if the error handler itself cannot complete normally?

Error paths are production code. They deserve the same engineering attention as the happy path.

[Internal Link: “Why the Same Bugs Keep Coming Back and How to Stop Them”]

Good error handling makes failure predictable

The purpose of error handling is not to hide every problem.

It is to make failures controlled, understandable and safe.

That means distinguishing expected conditions from genuine defects, handling only errors the application can meaningfully respond to, preserving diagnostic context, returning accurate responses, protecting sensitive implementation detail, retrying selectively and ensuring failed operations do not leave corrupted state.

For Levelworks, this is the practical connection between Error Free and Explainable & Debuggable App Health. An application will never eliminate every error, but it can make sure individual errors do not become mysterious behaviour that users and engineers have to reverse-engineer later.

The best error handling leaves very little ambiguity about what happened—and equally little ambiguity about what the application should do next.

‍

Continue Reading