keepcalculator

06 Numbers and Powers

Python Exception Refactoring: Freeze the Error Contract First?

Card No. 06·17101 · Updated · Standard formula, shown below

Yes, you should freeze the error contract before refactoring exception handling in Python. The error contract—the set of exception types, their attributes, and when they are raised—is the public API of your failure modes; changing it silently breaks callers, so pin it down first, then refactor the internals.

What Is an Error Contract?

An error contract is the implicit or explicit agreement between a function and its callers about what can go wrong. It specifies:

  • Exception types that may be raised (e.g., ValueError, KeyError, or a custom APIError).
  • Attributes on those exceptions (e.g., .status_code, .field).
  • Conditions under which each exception is raised (e.g., “raises ValueError if input is negative”).
  • Guarantees about state: does the function leave objects unchanged on failure? Does it close file handles?

In Python, the contract is rarely written down—it’s encoded in raise statements, except clauses, and docstrings. When you refactor, you risk accidentally changing any of these, which can break code that depends on them.

Why Freeze the Contract First?

Refactoring exception handling is risky because exceptions are control flow that crosses module boundaries. If you change the exception type from ValueError to TypeError, a caller’s except ValueError will no longer catch it. If you add a new attribute to an exception, existing code that unpacks it might fail. If you change the condition under which an exception is raised, you might turn a silent failure into a crash or vice versa.

Freezing the contract first means:

  1. Document the current behavior (what exceptions are raised, with what data, under what conditions).
  2. Write tests that assert that behavior.
  3. Refactor the internal code while keeping the tests green.
  4. If the contract must change, do it as a deliberate, separate step with a migration plan.

This approach turns a hidden contract into an explicit one, making the refactor safe and reviewable.

How to Freeze the Error Contract: A Step-by-Step Approach

Here’s a practical workflow you can apply to any Python module:

  1. Audit the current exceptions. Search for all raise statements and note the exception class and any arguments. Also note any except clauses that catch specific types—they reveal what callers expect.
  2. Write contract tests. For each exception type, create a test that triggers it and asserts the type, message, and attributes. For example:
# test_contract.py
def test_raises_value_error_on_negative():
    with pytest.raises(ValueError) as exc:
        my_module.calculate(-1)
    assert exc.value.args[0] == "input must be non-negative"
  1. Run the tests. They should pass before you touch any code. This gives you a safety net.
  2. Refactor with the tests running. Change the internals—move code, extract functions, simplify logic—but do not change what exceptions are raised or their attributes.
  3. If you must change the contract, do it explicitly. Update the tests first, then the code, then communicate the change to all callers (e.g., via a changelog).

Common Pitfalls When Refactoring Exceptions

Even with a frozen contract, several mistakes are common:

  • Accidentally swallowing exceptions. A refactor might replace a specific except ValueError with a bare except:, hiding bugs.
  • Changing exception chaining. In Python 3, raising a new exception from an except block automatically chains the original as __context__. If you use from None, you suppress it—that’s a contract change.
  • Modifying exception attributes. If you change exc.message to exc.msg, any caller using the old attribute breaks.
  • Raising a different type than documented. For example, raising RuntimeError instead of ValueError for invalid input.
  • Changing the timing of exceptions. Moving a check from the start of a function to later might mean a side effect happens before the exception, violating state guarantees.

Contract Tests vs. Unit Tests: What’s the Difference?

Contract tests are a specific kind of unit test that focuses on the interface, not the implementation. While a regular unit test might check that a function returns the correct result, a contract test checks that it fails in the agreed way. For example:

  • Unit test: assert calculate(5) == 10
  • Contract test: with pytest.raises(ValueError): calculate(-1)

Contract tests are crucial during refactoring because they catch changes in failure behavior that regular tests miss. They also serve as living documentation for future developers.

When Should You Change the Error Contract?

Freezing the contract doesn’t mean it’s immutable forever. Sometimes a change is necessary—for example, to add a new exception type for a new failure mode, or to include more context in an exception. But you should treat a contract change like a breaking API change:

  • Announce it in a changelog and release notes.
  • Provide a migration path (e.g., a compatibility shim that raises the old type for a deprecation period).
  • Update all callers in the same release or coordinate with them.

A common pattern is to introduce a new exception class that inherits from the old one, so existing except OldError still works. For example, changing ValueError to MyAppError(ValueError) keeps backward compatibility.

Frequently Asked Questions

Does freezing the error contract slow down refactoring?

It adds a small upfront cost—writing a few contract tests—but it saves time later by preventing subtle breakage. In practice, the tests are quick to write and run, and they make the refactor safer, so the overall time is often less than debugging a broken contract after the fact.

What if the existing error contract is already a mess?

That’s exactly when freezing is most valuable. Document the current behavior, even if it’s inconsistent, and then you can decide whether to fix it in a separate, deliberate change. Trying to clean up exceptions while refactoring other logic at the same time leads to confusion and bugs.

Should I use custom exception classes or built-in ones?

Custom exceptions are generally better for a public API because they give callers a stable, meaningful type to catch. Built-in exceptions like ValueError are fine for internal code, but they are too generic for a library. Whatever you choose, the contract is what matters—document it and test it.

How do I test that an exception has the right attributes?

Use pytest.raises as a context manager and inspect the exception object. For example:

with pytest.raises(APIError) as exc:
    api_call()
assert exc.value.status_code == 404
assert exc.value.detail == "Not found"

This ensures that not only the type but also the payload is part of the contract.

Is it okay to catch a broad exception during refactoring?

No, it’s a common anti-pattern. Catching Exception or using a bare except: hides all errors, including programming errors like KeyError or TypeError. If you need to catch multiple specific exceptions, list them explicitly: except (ValueError, TypeError):.

What about exception chaining with raise ... from?

Exception chaining is part of the contract. If you refactor code that raises a new exception from an except block, the original exception is automatically attached as __context__. If you use raise NewError from original, you set __cause__. Both are visible to callers, so changing them is a contract change. Test for them if they matter.

Related

Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.

More cards in this drawer

The whole Numbers and Powers drawer →