Error handling — normal, near-normal, and abnormal paths
Treating error handling as "wrap in try-catch" gives every failure the same treatment. As a result, user input mistakes become server-fault notifications, and real incidents drown in the noise.
What works is classifying possible outcomes into three and then deciding treatment.
What you'll learn in this chapter
- The distinction between normal, near-normal, and abnormal paths, and where the boundaries are
- Why to treat expected and unexpected failures differently
- When to use exceptions vs. return values
- Where to catch errors
- What to tell users and what to tell logs
Uses the status-code classification from HTTP.
What this chapter does not cover
Exception class hierarchy, mapping to HTTP status, and unified error response formats are covered by existing guides.
Three classes
| Class | Definition | Treatment when it happens |
|---|---|---|
| Normal | Succeeded as expected | Return the result as-is |
| Near-normal | Expected failure. Can happen per specification | Tell the user the reason and prompt the next action |
| Abnormal | Unexpected failure. Must not happen per specification | Detect and record. Don't show details to the user |
The boundary is "does the specification anticipate this". Not "is it an error".
Near-normal is a failure, but not surprising. At design time, "if this happens, return this" was decided. Abnormal is what wasn't decided, or couldn't be.
Concrete examples
Three outcomes in a signup process:
| Class | Example |
|---|---|
| Normal | Input was valid and signup completed |
| Near-normal | The email was already registered and signup couldn't complete |
| Abnormal | The database connection dropped and the process couldn't complete |
More examples.
| Situation | Normal | Near-normal | Abnormal |
|---|---|---|---|
| Login | Authentication succeeded | Password is wrong | Auth server doesn't respond |
| Payment | Payment completed | Insufficient funds | Payment API timed out |
| File fetch | Returned the file | No file with that ID | Storage isn't mounted |
| Search | Found 3 hits | Zero hits | Search index is corrupt |
A zero-hit search isn't even near-normal — often it's natural as normal. If "not found" is a normally expected outcome per the spec and the caller can treat it as an empty list, it isn't a failure. Classification isn't absolute; it depends on what the specification for that process defines.
Why split them
Mixing the three breaks these three at once.
| If mixed | What happens |
|---|---|
| How you tell users | Input mistakes get "system error occurred". They can't fix it |
| Monitoring | Input mistakes fire alerts. You go numb and miss real incidents |
| Logs | Near-normal fills the error log; abnormal gets buried |
After splitting:
| Near-normal | Abnormal | |
|---|---|---|
| Log level | info or warn | error |
| Alerts | Don't fire (watch the trend of the count) | Fire |
| User-facing message | Specific reason and next action | Generic message + a lookup identifier |
| HTTP status | 4xx | 5xx |
| Reproducibility | Same input and same data always give the same result | Depends on environment or time |
Not returning 5xx for near-normal is important. 5xx is the statement "this is our fault", and clients treat it as a retry candidate. Retrying an input mistake doesn't change the outcome. The classification of status codes in HTTP is the foundation of the judgment.
Exception or return value
Two styles for expressing near-normal:
// Style A: throw an exception
async function register(email: string): Promise<User> {
if (await exists(email)) {
throw new EmailAlreadyUsedError(email);
}
return save(email);
}
// Style B: return a Result type
type Result<T, E> = {ok: true; value: T} | {ok: false; error: E};
async function register(email: string): Promise<Result<User, 'email_already_used'>> {
if (await exists(email)) {
return {ok: false, error: 'email_already_used'};
}
return {ok: true, value: await save(email)};
}
| Exception | Result type | |
|---|---|---|
| Caller handling | Compiles even without writing it | Type forces handling |
| Readability | Normal flow reads cleanly | Branches multiply |
| Escape from deep nesting | Great | Each level has to write propagation |
| Fits | Abnormal path; escape from deep nesting | Near-normal |
Near-normal fits Result types better. For expected failures, callers must handle them, and type-enforcement stops omissions. With exceptions, swallowing or forgetting goes unnoticed.
That said, languages and frameworks have idioms. In an exception-first environment, using only Result types adds conversions at library boundaries. Not mixing styles — unifying within the project — has larger effect.
Abnormal paths fit exceptions. The layer where they occur has nothing to decide with, and escaping deep nesting to record centrally at the top matches.
Where to catch
Don't catch at the site of occurrence is the principle.
// Bad: swallow on the spot
try {
await sendEmail(user);
} catch (e) {
console.log(e); // Caller proceeds unaware of failure
}
Errors should propagate up to a layer that can act. Low layers know "what happened" but not "what to do". Higher layers can decide.
In asynchronous code, propagation paths don't match the source order. A failure in a call missing its await occurs after leaving that try, so it isn't caught (Event loop).
Responsibilities split by layer:
| Layer | Does |
|---|---|
| Infrastructure | Detect technical failures and throw. Don't swallow |
| Domain / use case | Convert to business meaning. Near-normal is judged here |
| Presentation | Convert to HTTP status and message. Catch centrally here |
Placing a single common handler at the application's entrance is the base form. Scattering try-catch per handler makes treatment inconsistent and leaves gaps.
There are exceptional cases where catching on the spot is fine.
- Failures where processing may continue (email delivery failure). But always leave a record
- When retrying. Decide count and interval (Retries and idempotency)
- Switching to another means (fetch from origin if the cache is unreadable)
What to tell
The audience decides the information — and the directions are opposite.
| User | Log | |
|---|---|---|
| Near-normal | Reason and next action specifically | What happened, concisely |
| Abnormal | Generic phrasing + an identifier | Everything needed for root-cause analysis |
// Near-normal: they know what to do
"This email is already registered. Please log in or use a different address."
// Abnormal: don't expose internals; give an identifier for follow-up
"We couldn't complete the request. Please try again later. (Error ID: a3f8c2)"
This message itself carries a judgment about how much you are willing to reveal. Replying "already registered" lets anyone confirm that an address belongs to a member. A service that wants to protect its member list always responds as if sign-up succeeded, and emails the existing address instead. You are choosing between clarity and keeping membership private (Laravel API Development Guide — Authentication works through the same judgment on the login side).
Don't show stack traces or SQL to users in abnormal paths. Table names and file paths are useful to attackers. Instead, expose a lookup identifier so support can correlate to logs.
Logs, conversely, keep everything needed to diagnose. But passwords, tokens, and other secrets must be masked. Logs live long and are read by many.
How to structure a written answer
For "give a concrete example of each of normal, near-normal, and abnormal", here's how to build it.
- State the classifying criterion first — divided by "whether the specification anticipates it"
- Show three outcomes of one process — the contrast is clearer than pulling examples from different processes
- Touch handling differences — near-normal tells the user the reason; abnormal records and returns generic messages. It is stronger if you add that even a near-normal message carries a judgment about how much may be revealed
Keeping the same process is the point. "Normal is login success, near-normal is file missing, abnormal is DB outage" mixes processes and the classifying criterion doesn't come across.
Sample:
Take signup as an example. Normal is when the input is valid and signup completes. Near-normal is when the email is already registered and signup can't proceed — an expected failure per spec, so we tell the user the reason and prompt an alternative address. Abnormal is when the database connection drops and the process can't complete — an unexpected failure, so we record it, notify monitoring, and return a generic message to the user.
Common misconceptions
"All errors are abnormal." — Expected failures are near-normal. Input mistakes and insufficient funds are outcomes anticipated per spec, not incidents.
"Near-normal can return 500." — 5xx is the declaration "server-side problem". Clients treat it as a retry candidate. Near-normal returns 4xx.
"Catch at the site of occurrence." — Propagate to a layer that can act. Low layers know what happened but not what to do.
"More detail is kinder to the user." — Near-normal tells the reason, but even there you judge how much may be revealed. Exposing internals in abnormal is more dangerous still. Only expose a lookup identifier.
"Exceptions are enough for error handling." — Near-normal is less leaky with Result types, because handling is type-enforced.
Exercises
Q1. For login, give one example each of normal, near-normal, and abnormal.
Answer and explanation
Answer:
| Class | Example | Treatment |
|---|---|---|
| Normal | Authenticated with correct email and password | Issue a session and transition to the app |
| Near-normal | Wrong password | Return 401 with "Email or password is incorrect" |
| Abnormal | Can't reach the DB holding credentials | Return 500, record, and notify monitoring |
Vagueing the near-normal to "email or password" is deliberate. Returning "wrong password" would tell an attacker the email is registered. Even in near-normal, how much to reveal has a security judgment.
"Account locked" is also near-normal — a spec-defined failure, with guidance on unlock.
Q2. Point out the problems in this code.
async function createOrder(input: OrderInput) {
try {
const user = await userRepo.find(input.userId);
const order = await orderRepo.save(new Order(user, input.items));
await sendConfirmationEmail(user, order);
return order;
} catch (e) {
console.log(e);
return null;
}
}
Answer and explanation
Answer: Three problems.
1. Three kinds of failures get one treatment
"User not found" from userRepo.find (near-normal), "DB outage" from orderRepo.save (abnormal), and "email send failure" from sendConfirmationEmail (continuable) all fall into the same catch. The caller receives null and cannot tell what happened.
2. Email failure erases the order
The order is saved but null is returned, so the caller assumes failure. Since data exists, retrying causes a duplicate order. Continuable failures should be caught on the spot and recorded — processing continues.
3. Returning null discards information
The caller has no way to know the reason. Logging with console.log alone lacks level and context.
Direction of improvement:
async function createOrder(input: OrderInput): Promise<Result<Order, OrderError>> {
const user = await userRepo.find(input.userId);
if (!user) return {ok: false, error: 'user_not_found'}; // Near-normal
// DB outage propagates up (abnormal)
const order = await orderRepo.save(new Order(user, input.items));
// Continuable failure — catch on the spot but record
try {
await sendConfirmationEmail(user, order);
} catch (e) {
logger.warn('Failed to send confirmation email', {orderId: order.id, error: e});
}
return {ok: true, value: order};
}
Q3. You get "300 500-errors per day". Investigation shows they're all "invalid input". What's the problem?
Answer and explanation
Answer: Near-normal is being treated as abnormal, and monitoring isn't functioning.
Invalid input is a failure anticipated per spec, so it's near-normal. What should be returned is 400 or 422, not 500.
Three consequences:
| Consequence | Content |
|---|---|
| Monitoring dies | You go numb to 300 items of noise and miss the one real incident |
| Users can't fix it | "System error" gives them no signal to fix their input |
| Clients retry | 5xx signals a retry candidate; they repeat the same failure |
Order of action:
- Return
4xxfor validation failures — distinguish400(bad format) from422(format valid, business rule violated) - Lower the log level — from
errortoinfoorwarn - Revisit alert conditions — fire on
5xxcount. Put4xxon a trend dashboard - Watch for surges separately — a sudden increase in
4xxmay indicate a UI defect or an attack
Point 4 matters too. Near-normal isn't ignorable — just no alert. Observe the trend.
Summary
- Outcomes split into normal, near-normal, abnormal. Boundary is "anticipated per spec"
- Near-normal is expected failure (input mistake, insufficient funds); abnormal is unexpected (DB outage, timeout)
- Mixing breaks three at once: user messaging, monitoring, and logs
- Near-normal is
4xx; abnormal is5xx.5xxsignals a retry candidate; whether it is actually safe to resend depends on idempotency - Near-normal fits Result types; abnormal fits exceptions
- Errors propagate to a layer that can act, not caught where they occur. Place a central handler at the entrance
- Tell users the near-normal reason concretely; for abnormal, generic phrasing with a lookup identifier
- Logs keep everything needed for diagnosis, with secrets masked
- Laravel × DDD Guide — Error handling — Exception hierarchy and per-layer responsibilities
- Laravel API Development Guide — Error responses — Unified error response format
What to read next
- Retries and idempotency — Designing the "recoverable via retry" subset of abnormal