Skip to main content

🧯 Error Handling in Node, Python & PHP

Software fails: files go missing, networks drop, users type nonsense. What separates a fragile script from a robust backend is how gracefully it handles the failures it can't prevent. This lesson compares the language-level error tools of Node.js, Python, and PHP — the try blocks, the raise/throw keywords, custom error classes, and the cleanup step that always runs.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Write try/catch (JS), try/except/else/finally (Python), and try/catch/finally (PHP) blocks correctly
  • Throw / raise errors and define your own custom error & exception classes
  • Explain how errors propagate up the call stack until something catches them
  • Use finally (and else) for reliable cleanup
  • Apply best practices: catch narrowly, never swallow silently, fail loudly at the right layer

Estimated Time: 30–40 minutes  •  Difficulty: Intermediate

Hands-on: Harden a "parse a config value" function in all three languages with a custom error and a guaranteed-cleanup step.

In This Lesson

Why Structured Error Handling?

An error (or exception) is a signal that a piece of code could not do its job. Rather than letting the program limp on with bad data, the language stops the current path and hands control to whoever is prepared to deal with the problem. Structured handling gives you three things a scattering of if checks cannot: a clear place to react, a way to separate the happy path from the failure path, and a guarantee that cleanup still runs.

💡 A useful analogy: Think of throw/raise as pulling the emergency cord on a train. Everything after it in the current carriage stops immediately, and the train keeps rolling backward through the carriages (the call stack) until it reaches a station (a catch/except) that's staffed to handle the emergency. If no station is staffed, the whole train stops — an unhandled crash.

All three languages share the same core idea — a protected block plus a handler — but they differ in vocabulary and in a few important details, which we'll line up next.

📖 Key Terms

Throw / raise: to signal an error, interrupting normal flow.

Catch / except: to intercept a thrown error and decide what to do.

Propagation: an uncaught error travelling up the call stack to the caller.

Finally: a block that runs whether or not an error occurred — for cleanup.

The try Block in Three Languages

Here is the same defensive pattern — attempt something risky, react on failure, always clean up — expressed in each language.

JavaScript (Node.js) — try / catch / finally

try {
  const data = JSON.parse(rawInput);   // may throw a SyntaxError
  console.log(data.name);
} catch (err) {
  // err is the thrown Error object
  console.error('Could not parse input:', err.message);
} finally {
  console.log('Done attempting.');     // always runs
}

JavaScript has a single catch clause. To react differently to different error types, inspect the caught object inside the block — usually with instanceof:

catch (err) {
  if (err instanceof SyntaxError) {
    // handle malformed JSON
  } else {
    throw err;   // re-throw anything we didn't expect
  }
}

Python — try / except / else / finally

try:
    data = json.loads(raw_input)          # may raise JSONDecodeError
except json.JSONDecodeError as err:
    print(f'Could not parse input: {err}')
except (KeyError, ValueError) as err:     # catch several types at once
    print(f'Bad data: {err}')
else:
    print(data['name'])   # runs ONLY if no exception was raised
finally:
    print('Done attempting.')             # always runs

Python is the richest here: you can list multiple typed except clauses, and it adds an else block that runs only when the try succeeded — a clean way to keep the success-only code out of the protected block.

PHP — try / catch / finally with Throwable

<?php
try {
    $data = json_decode($rawInput, true, 512, JSON_THROW_ON_ERROR);
    echo $data['name'];
} catch (JsonException $err) {           // typed catch, like Python
    echo 'Could not parse input: ' . $err->getMessage();
} catch (Throwable $err) {               // catch-all safety net
    echo 'Unexpected: ' . $err->getMessage();
} finally {
    echo 'Done attempting.';             // always runs
}

Like Python, PHP supports multiple typed catch clauses. The base type to catch is Throwable, which sits above both Exception and Error.

FeatureJavaScriptPythonPHP
Protected blocktrytrytry
Handlercatch (one)except (many, typed)catch (many, typed)
Success-only blockelse
Always-runs blockfinallyfinallyfinally
Base typeErrorException / BaseExceptionThrowable

⚠️ Async errors in Node

A plain try/catch only catches errors thrown synchronously. To catch an error from a promise you must await it inside the try (or attach .catch()). A rejected promise that is never awaited or caught becomes an unhandled rejection. This lesson stays language-level; the async specifics are covered in Module 11.

Throwing & Raising

When your own code detects a bad situation, signal it explicitly rather than returning a magic value like -1 or null that the caller might forget to check.

// JavaScript — throw an Error instance
function withdraw(balance, amount) {
  if (amount > balance) {
    throw new Error('Insufficient funds');
  }
  return balance - amount;
}
# Python — raise an exception instance
def withdraw(balance, amount):
    if amount > balance:
        raise ValueError('Insufficient funds')
    return balance - amount
<?php
// PHP — throw an exception instance
function withdraw(float $balance, float $amount): float {
    if ($amount > $balance) {
        throw new InvalidArgumentException('Insufficient funds');
    }
    return $balance - $amount;
}

⚠️ Always throw an object, not a string

JavaScript technically lets you throw 'oops', but don't — a plain string has no .message, no .stack, and breaks instanceof checks. Always throw an Error (JS), an Exception subclass (Python), or a Throwable (PHP) so handlers get a message and a stack trace.

Custom Error Classes

Built-in error types are fine, but a named error class makes your intent explicit and lets callers catch exactly the failure they care about. In every language you make one by extending the base error type.

// JavaScript — extend Error
class InsufficientFundsError extends Error {
  constructor(deficit) {
    super(`Short by ${deficit}`);
    this.name = 'InsufficientFundsError'; // so stack traces read well
    this.deficit = deficit;               // attach useful context
  }
}

// caller can now target it precisely:
try {
  withdraw(50, 80);
} catch (err) {
  if (err instanceof InsufficientFundsError) {
    console.log('Top up by', err.deficit);
  }
}
# Python — subclass Exception
class InsufficientFundsError(Exception):
    def __init__(self, deficit):
        super().__init__(f'Short by {deficit}')
        self.deficit = deficit

try:
    withdraw(50, 80)
except InsufficientFundsError as err:
    print('Top up by', err.deficit)
<?php
// PHP — extend Exception
class InsufficientFundsException extends Exception {
    public function __construct(
        public readonly float $deficit
    ) {
        parent::__construct("Short by {$deficit}");
    }
}

try {
    withdraw(50, 80);
} catch (InsufficientFundsException $err) {
    echo 'Top up by ' . $err->deficit;
}

✅ Why bother with custom classes?

They turn error handling into a clear contract. A caller can write catch (InsufficientFundsError) and know it will not accidentally swallow an unrelated TypeError. They also carry structured data (here, deficit) instead of forcing you to parse a message string.

Propagation & Cleanup

If a block doesn't catch an error, it doesn't vanish — it propagates up to the function that called it, then that function's caller, and so on up the stack until something catches it or the program crashes. This is powerful: low-level code can raise, and a single high-level handler can deal with it.

An error propagating up the call stack A saveOrder function throws an error that passes uncaught through checkout and finally reaches the request handler, which catches it. requestHandler() catches the error here checkout() no catch — passes it up saveOrder() throws the error the error travels upward until caught
Figure 1 — saveOrder throws; checkout ignores it; the top-level requestHandler catches it once, in one place.

finally always runs

The finally block executes whether the try succeeded, threw, or even returned — making it the reliable place to release resources like file handles or database connections.

# Python — the file closes even if processing raises
f = open('data.txt')
try:
    process(f.read())
finally:
    f.close()          # guaranteed

# Idiomatic Python prefers a context manager, which does this for you:
with open('data.txt') as f:
    process(f.read())  # file auto-closes on exit, error or not
// JavaScript — release a lock no matter what
const release = await lock.acquire();
try {
  await doWork();
} finally {
  release();           // guaranteed
}

💡 else vs the end of try (Python)

Putting success-only code in an else block instead of at the bottom of try keeps the protected region as small as possible. That way a KeyError from your success code isn't accidentally caught by the except meant for the risky call.

Best Practices

The mechanics are similar across languages; the discipline is what makes error handling good. These principles hold in Node, Python, and PHP alike.

✅ Do

  • Catch narrowly. Handle the specific type you expect (JSONDecodeError, InsufficientFundsError) rather than a blanket catch-all.
  • Add context, then re-throw. If a layer can't fully handle an error, wrap it with more detail and re-throw so an upper layer can decide.
  • Clean up in finally (or a context manager / using-style helper).
  • Fail fast on programmer errors. A null where you required a value is a bug — let it crash loudly in development.
  • Log with the stack trace, not just the message.

⚠️ Don't

  • Swallow silently. An empty catch {} / except: pass hides bugs and produces the dreaded "nothing happened and no error."
  • Catch everything at the lowest level. Let errors propagate to a layer that can actually make a decision (retry, respond 500, show a message).
  • Use exceptions for normal flow. "User not found" during a routine lookup is often a return value, not an exceptional event.
  • Leak internals to users. Log the full trace server-side; show the user a friendly, generic message.
# Anti-pattern: the silent swallow — never do this
try:
    risky()
except Exception:
    pass          # the error is gone forever; good luck debugging

# Better: catch what you expect, log the rest, re-raise the unknown
try:
    risky()
except TimeoutError as err:
    logger.warning('Retrying after timeout: %s', err)
    retry()

Hands-on Exercise

🏋️ A Resilient Config Reader

Objective: Write a getPort(config) function that returns a valid port number, in all three languages. It must:

  1. Throw/raise a custom error (e.g. ConfigError) if the "port" key is missing or not a number between 1 and 65535.
  2. Use a finally step that logs "config check complete" whether it succeeded or failed.
  3. Be called inside a try that prints a friendly message on failure.
💡 Hint

Define the custom error by extending the base type (Error / Exception / Exception). Do your validation, and if it fails, throw/raise your custom error with a clear message. Put the log line in finally so it runs on both paths.

✅ Solution
// JavaScript
class ConfigError extends Error {
  constructor(msg) { super(msg); this.name = 'ConfigError'; }
}

function getPort(config) {
  try {
    const port = config.port;
    if (typeof port !== 'number' || port < 1 || port > 65535) {
      throw new ConfigError(`Invalid port: ${port}`);
    }
    return port;
  } finally {
    console.log('config check complete');
  }
}

try {
  console.log(getPort({ port: 3000 }));  // 3000
  console.log(getPort({}));              // throws
} catch (err) {
  if (err instanceof ConfigError) console.error('Config problem:', err.message);
  else throw err;
}
# Python
class ConfigError(Exception):
    pass

def get_port(config):
    try:
        port = config.get('port')
        if not isinstance(port, int) or not (1 <= port <= 65535):
            raise ConfigError(f'Invalid port: {port}')
        return port
    finally:
        print('config check complete')

try:
    print(get_port({'port': 3000}))   # 3000
    print(get_port({}))               # raises
except ConfigError as err:
    print('Config problem:', err)
<?php
class ConfigError extends Exception {}

function getPort(array $config): int {
    try {
        $port = $config['port'] ?? null;
        if (!is_int($port) || $port < 1 || $port > 65535) {
            throw new ConfigError("Invalid port: " . var_export($port, true));
        }
        return $port;
    } finally {
        echo "config check complete\n";
    }
}

try {
    echo getPort(['port' => 3000]) . "\n";  // 3000
    echo getPort([]) . "\n";                // throws
} catch (ConfigError $err) {
    echo 'Config problem: ' . $err->getMessage() . "\n";
}

🎯 Quick Quiz

Question 1: Which block is guaranteed to run whether or not an error was thrown, making it the right place for cleanup?

Question 2: In PHP, what is the broadest type you can catch to intercept both Exception and Error?

Question 3: Why is an empty catch {} / except: pass considered an anti-pattern?

Summary & Quiz

🎉 Key Takeaways

  • All three languages share the pattern: a protected block + a handler + a cleanup step.
  • JS has a single catch (branch by instanceof); Python and PHP allow multiple typed except/catch clauses.
  • Python adds an else block; PHP's catch-all base type is Throwable.
  • Always throw an object (Error / Exception / Throwable), and prefer custom classes for precise catching and structured context.
  • Uncaught errors propagate up the stack; finally guarantees cleanup; never swallow errors silently.

📚 Further Reading

🚀 What's Next?

With resilient code in hand, the next lesson puts it to work reading and writing the outside world — working with files and JSON in Node, Python, and PHP, where error handling and cleanup really earn their keep.

🎉 Nice work!

Your code now fails gracefully instead of mysteriously. Let's give it some data to chew on.