In short

Give callers a stable, minimal error and keep the details in restricted logs. Map each failure to a status the client can act on, such as 400, 403, 409 or 503, in an RFC 9457 problem body with a correlation id, never a stack trace or SQL. Fail closed: an unavailable authorization check is a 503, not an allow. Roll back partial work in one transaction, and make retries safe with idempotency keys bound to the original inputs.

  • An error contract covers the response, the state change, retry behavior and the evidence kept.
  • A dependency failure during an authorization check must deny or return 503, never allow.
  • Keep related writes in one transaction so a failure part-way through leaves no partial effect.
  • A lost response after commit is not a failure; idempotency keys let the client retry safely.
  • Turn off framework debug output in production, and test proxy and framework error pages as well as your own.

Verify it

  1. Make the policy service time out and confirm a 503 with no state change.
  2. Inject a failure after the first write and confirm every write in the operation rolled back.
  3. Drop the response after commit, retry with the same key and confirm one effect and the same result.
  4. Reuse an idempotency key with a different amount and confirm a 409 with no change.
  5. Trigger an unexpected exception and confirm the body carries a request id but no stack trace, SQL or secret, and that the restricted log has the entry.
  6. Run python manage.py check --deploy or your framework equivalent, then request a missing route and a failing upstream through the production proxy.
Applies to

Failing closed without disclosing internals. Uniform messages do not prove uniform timing or successful recovery.

Part of
Workflow: Fix a finding
Learning goals and prerequisitesAbout 28 minutes: 7 to read, 4 on the code and 17 for the exercise

What you will learn

  • Distinguish denied, unavailable and uncertain outcomes.
  • Verify state rollback rather than only error text.
  • Design retriable failures without duplicate business effects.

Before you start

Backend developers designing API error responses and failure paths, and reviewers of exception handling.

  • Distinguish expected rejection, dependency failure and programming errors in a handler.
Go to the guided practice

Specify an error as a response and a state outcome

An error contract includes what the caller sees, what state changed, what may be retried and what evidence operators retain. Returning a generic 500 fixes only the presentation layer. A handler can hide its stack trace while still granting access because its authorization dependency timed out. Conversely, indiscriminately swallowing errors can make an operation appear successful even though no durable work happened. OWASP Top 10:2025 now gives this its own category, A10 Mishandling of Exceptional Conditions.

Consider a transfer of 30 credits from A to B. A starts with 100 and B with 10. The intended success is A=70 and B=40, with one transfer record. Authentication failure should produce 401 and no mutation. A known permission denial should produce 403 and no mutation. A malformed amount should produce 400. An unavailable permission service should produce the documented temporary failure, here 503, and must not be treated as an allow decision. An unexpected server failure should return a stable error code and opaque request id while keeping sensitive internals in restricted diagnostics.

Preserve the invariant on every failure path

A dangerous pattern catches any policy exception and substitutes true, or catches a failed debit and continues to credit the recipient. Group local debit, credit and ledger insertion in a transaction so a failure before commit rolls all three back. Check expected failures deliberately and let an appropriate boundary handler convert unexpected errors. Do not use a broad catch block to erase the difference between denied, unavailable and completed-but-unacknowledged.

A timeout after commit is different from a failure before commit. If the database committed the transfer but the response connection broke, the caller cannot know whether the transfer occurred. An idempotency key tied to caller, operation and material input lets a retry retrieve the original result. Without that context, advising 'try again' can duplicate the operation. External payments add another uncertainty boundary because a local database rollback cannot undo an accepted provider charge; retain an operation identity and reconcile before issuing a new one.

The Python program below implements the transfer contract with SQLite and returns RFC 9457 problem bodies. A policy outage becomes 503 before any write. Debit, credit, ledger row and the stored idempotent result share one transaction, so an exception between debit and credit rolls everything back and the caller sees only internal-error with its request id, while the log records the category without the exception text. A retry with the same key and inputs replays the stored result; the same key with a different amount returns 409. Run python3 transfer_errors.py. Eight threads racing with one key also produced exactly one transfer; /domains/appsec/business-logic/transaction-races/ covers that race in depth.

Fixedtransfer_errors.pyPython
# Run: python3 transfer_errors.py
import json
import logging
import sqlite3

log = logging.getLogger("transfers")


class PolicyUnavailable(Exception):
    pass


def problem(status, kind, request_id):
    """An RFC 9457 problem body (application/problem+json): stable fields, no internals."""
    return status, {"type": f"https://errors.example.test/{kind}", "title": kind,
                    "status": status, "instance": f"urn:request:{request_id}"}


def transfer(db, policy, caller, key, amount, recipient, request_id, fault=None):
    if type(amount) is not int or amount <= 0:
        return problem(400, "invalid-amount", request_id)
    try:
        allowed = policy(caller, recipient, amount)
    except PolicyUnavailable:
        return problem(503, "policy-unavailable", request_id)  # never treated as an allow
    if not allowed:
        return problem(403, "forbidden", request_id)
    def replay():  # a retry: the same inputs replay the result, changed inputs conflict
        stored = db.execute("SELECT amount, recipient, result FROM idempotency "
                            "WHERE caller = ? AND key = ?", (caller, key)).fetchone()
        if stored and stored[:2] != (amount, recipient):
            return problem(409, "idempotency-key-reused", request_id)
        return stored and (200, json.loads(stored[2]))

    if earlier := replay():
        return earlier
    try:
        with db:  # debit, credit, ledger row and stored result commit or roll back together
            if db.execute("UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?",
                          (amount, caller, amount)).rowcount != 1:
                return problem(409, "insufficient-funds", request_id)
            if fault:
                fault()  # test hook: fail between the debit and the credit
            db.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (amount, recipient))
            cur = db.execute("INSERT INTO transfers (src, dst, amount) VALUES (?, ?, ?)",
                             (caller, recipient, amount))
            result = {"transfer": cur.lastrowid, "amount": amount, "recipient": recipient}
            db.execute("INSERT INTO idempotency VALUES (?, ?, ?, ?, ?)",
                       (caller, key, amount, recipient, json.dumps(result)))
        return 200, result
    except sqlite3.IntegrityError:  # a concurrent request with the same key committed first
        return replay()
    except sqlite3.Error as e:
        # Restricted diagnostics: category and correlation id, not the exception text.
        log.error("transfer failed request=%s category=%s", request_id, type(e).__name__)
        return problem(500, "internal-error", request_id)


def fresh():
    db = sqlite3.connect(":memory:")
    db.executescript("""
        CREATE TABLE accounts (id TEXT PRIMARY KEY, balance INTEGER NOT NULL);
        CREATE TABLE transfers (id INTEGER PRIMARY KEY, src TEXT, dst TEXT, amount INTEGER);
        CREATE TABLE idempotency (caller TEXT, key TEXT, amount INTEGER, recipient TEXT,
                                  result TEXT, PRIMARY KEY (caller, key));
        INSERT INTO accounts VALUES ('A', 100), ('B', 10);
    """)
    return db


def state(db):
    return (dict(db.execute("SELECT id, balance FROM accounts")),
            db.execute("SELECT COUNT(*) FROM transfers").fetchone()[0])


def allow(*_):
    return True


def unavailable(*_):
    raise PolicyUnavailable()


def crash():
    raise sqlite3.OperationalError("db password=TRAINING-SECRET table transfers")


logging.basicConfig(level=logging.CRITICAL)
db = fresh()
status, body = transfer(db, unavailable, "A", "K1", 30, "B", "req-1")  # F1
assert status == 503 and state(db) == ({"A": 100, "B": 10}, 0)
status, body = transfer(db, allow, "A", "K1", 30, "B", "req-2", fault=crash)  # F2
assert status == 500 and "TRAINING-SECRET" not in json.dumps(body)
assert state(db) == ({"A": 100, "B": 10}, 0)  # the debit was rolled back
first = transfer(db, allow, "A", "K1", 30, "B", "req-3")  # F3: committed, reply lost
assert first[0] == 200 and state(db) == ({"A": 70, "B": 40}, 1)
assert transfer(db, allow, "A", "K1", 30, "B", "req-4") == first  # F4: replayed
assert transfer(db, allow, "A", "K1", 40, "B", "req-5")[0] == 409  # F5: key reused
assert state(db) == ({"A": 70, "B": 40}, 1)
assert transfer(db, allow, "A", "K2", True, "B", "req-6")[0] == 400  # bool is not an amount
assert transfer(db, lambda *_: False, "A", "K3", 30, "B", "req-7")[0] == 403
print("PASS: 503, rollback, replay and conflict cases keep A=70, B=40, one transfer")

Inject failures at meaningful boundaries

For each test, record balances and transfer rows before and after, not only the status. Fail after debit but before credit: the transaction must restore A=100 and B=10 with zero records. Fail after commit but before response: state must remain A=70 and B=40 with one record; a retry using the same key returns the existing result and does not transfer again. A log message alone cannot tell these two cases apart without the commit or operation identity.

Public error bodies should avoid SQL statements, file paths, tokens and exception strings that include submitted secrets. Detailed internal diagnostics still need access control, redaction and retention limits. Log structured error categories and request ids, as /domains/runtime/detection/security-event-logging/ describes, so a user-facing message can be correlated without including confidential content. Test proxy-generated and framework-generated errors as well as your own handlers; a debug page or upstream error body can bypass an otherwise careful application mapper. Preserve useful validation messages for ordinary users, while keeping account-enumeration and authorization information within the product's chosen disclosure policy.

Switch off framework error detail in production

Framework and proxy error pages bypass your own mapper, so check their production settings explicitly and request a failing route through the deployed path. The table lists the switch for common stacks. Debug consoles, Actuator endpoints, exposed .git folders and the other deployment-side leaks, with a probe to run after each release, are in /domains/appsec/secure-coding/information-disclosure-misconfiguration/.

Switch off framework error detail in production
StackProduction settingWhat it prevents
DjangoDEBUG = False; run manage.py check --deployDebug pages with settings, SQL and tracebacks
Spring Bootspring.web.error.include-stacktrace=never and include-message=never, the defaults (server.error.* before Spring Boot 4)Stack traces and exception messages in the default error body
ASP.NET CoreUseExceptionHandler() and AddProblemDetails(); developer exception page only in DevelopmentException pages in responses
ExpressNODE_ENV=production and a final error middlewareerr.stack in the built-in error response
Reverse proxyCustom 502, 503 and 504 pagesUpstream error bodies and server versions

A real-world failure

Apple's 2014 'goto fail' bug (CVE-2014-1266) is the cautionary tale: a duplicated goto fail statement skipped the signature check on the TLS server key exchange, so a man-in-the-middle could impersonate any TLS server to iOS and OS X clients. It is the archetype of failing open; error paths must fail closed and unreachable code must be caught in review and tests.

Handle observability failures without hiding the business result

Suppose the transfer commits, then the audit-log transport fails. If the handler returns an ordinary failure and encourages a fresh retry, the caller may transfer twice unless idempotency preserves the original operation. Decide whether the audit event is part of the transaction's durable obligation. An outbox can store the audit intent with the transfer so a logging outage delays delivery without erasing the business result. For an action whose policy requires synchronous durable audit, fail before the action when that requirement cannot be met; do not invent the policy only after the transfer commits.

Error categories also affect retries. A 403 is a permission decision and should not trigger automatic retry with broader credentials. A 400 requires corrected input. A 503 may be retried under a bounded backoff using the same operation identity, ideally with a Retry-After header. A timeout after an unknown commit requires reconciliation or idempotent replay. Returning all of these as 200 with error text makes clients and monitoring less able to distinguish them; returning every condition as 500 can create avoidable retry storms.

Test cleanup paths as carefully as the original operation. If rollback or temporary-file removal also fails, preserve the primary failure and track residual state without exposing a raw nested exception to the caller. Never continue with an allow decision because constructing an error page threw another error. For streaming responses, headers or body bytes may already be sent before a later failure; the boundary must prevent sensitive release before authorization rather than relying on a final error mapper to take bytes back.

These cases explain why a global exception handler is necessary but insufficient. The operation needs an explicit state machine, and the error boundary needs a stable presentation and correlation contract.

Guided practice

Apply a complete failure contract to a two-account transfer.

Supplied scenario material
Plain text
Initial: A=100, B=10, ledger empty. Amount=30, recipient=B, idempotency key=K1 for caller A.
Policy unavailable ->503; denied ->403; invalid input ->400. No mutation in those cases.
Debit, credit and ledger share one database transaction.
F1: policy throws timeout before transaction.
F2: permission allowed, database throws after debit before credit.
F3: commit succeeds, response socket closes.
F4: client retries F3 with K1, amount=30, recipient=B.
F5: client reuses K1 with amount=40.
Expected idempotency contract: same material inputs replay original result; changed inputs ->409 conflict.
Diagnostic exception sample: db password=TRAINING-SECRET, table transfers, request req-7.
  1. 1. Classify precondition failure

    Evaluate F1 and explain why defaulting permission to true is invalid.

    Expected result and reasoning

    Return 503, A=100, B=10, zero ledger rows. Unavailable evidence is neither an allow nor a denial of policy; granting the transfer would bypass the required authorization decision.

  2. 2. Apply transactional rollback

    Trace F2 including the transient debit.

    Expected result and reasoning

    Inside the transaction A temporarily becomes 70. Failure rolls it back; durable state is A=100, B=10 and zero rows. Returning 500 without rollback would not repair the invariant.

  3. 3. Resolve uncertain acknowledgement

    Trace F3 and F4 as one sequence.

    Expected result and reasoning

    After F3, A=70, B=40 and one ledger row. F4 returns that stored result with no additional debit or credit. The lost response did not undo the commit.

  4. 4. Prevent semantic key reuse

    Evaluate F5 and draft public versus internal diagnostics for req-7.

    Expected result and reasoning

    F5 returns 409 with unchanged A=70,B=40. The public body may contain error=internal_error and requestId=req-7, not TRAINING-SECRET or SQL details. Internal structured diagnostics record the failure category and operation identity with secret fields removed.

Your deliverable: A response/state/retry table that distinguishes rollback, committed uncertainty and idempotency conflict.

Paper exercise: balances, failures and the idempotency contract are supplied. transfer_errors.py above runs cases F1 to F5 against a real SQLite transaction.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP Error Handling
  2. MITRE CWE-209: sensitive information in error messages. CWE 4.20.
  3. RFC 9457: Problem Details for HTTP APIs. July 2023.
  4. OWASP Top 10:2025 A10 Mishandling of Exceptional Conditions
  5. Django deployment checklist. Django 6.1.
  6. ASP.NET Core: handle errors
  7. Express: error handling
  8. NVD: CVE-2014-1266, Apple TLS 'goto fail'

Reference information

SDLC phase
Development
ASVS 5.0.0 requirements
v5.0.0-16.5.1 · v5.0.0-16.5.3 · v5.0.0-13.4.2
OWASP WSTG v4.2 tests
WSTG-ERRH-01 · WSTG-ERRH-02

Download this guide as Markdown