Chapter 3 — The Error Vocabulary
Errors that fail loudly are easier to debug than errors that fail silently. An error vocabulary turns that principle into a menu of typed exceptions, each with a specific meaning and a specific must-bubble or must-handle status.
The previous chapter established the coding standards. This
chapter develops one of them in depth: the exception-handling
discipline. The discipline rests on
ccai.errors
— a small subpackage of codecraftai-foundations whose job is to
provide the typed exception
vocabulary every other ccai package uses.
The package's contents are simple in shape but consequential in
meaning. Eighty exception classes; most are RuntimeError
subclasses; three are deliberately not — SemanticError and
ConfigurationError inherit from BaseException directly, and
AbstractMethodError from RuntimeError. The inheritance
choices encode the must-bubble and must-handle decisions that
the rest of the portfolio relies on.
The three special types
Three exception types in the vocabulary have semantics that matter universally across the portfolio. Every package documents them; every package's exception handling respects them. This section is the canonical statement of what each means.
SemanticError(BaseException) — must always bubble
class SemanticError(BaseException):
"""
The base error object for errors that indicate that there is an issue with
a piece of automation code and with the way the Automation Kit code is being
utilized.
"""
SemanticError signals API misuse. When code raises it, the
caller has invoked the API in a way the API can't make sense of —
passed contradictory parameters, called a method out of order,
constructed an object with mutually-exclusive options. These are
the caller's bugs, and they need to surface to the caller.
The inheritance is deliberate: BaseException, not Exception.
This means except Exception: does not catch
SemanticError. A bare except Exception: block — even a
broad recovery handler in well-meaning library code — does not
silently absorb a SemanticError. The error bubbles up through
every Exception-targeted handler and reaches the top of the
stack, where the developer who made the API-misuse mistake sees
the failure with full context.
The discipline:
- Code may not catch
SemanticError. Ever. The only legitimate handler is the outermost layer of the application, where the misuse is logged and the process exits. - Code may raise
SemanticErrorwhen the caller has violated the API's contract in a way the API can detect.
Concrete example. Consider a credential class that requires either a password or a keyfile but not both:
class SshCredential(BaseCredential):
def __init__(self, *, identifier: str, password: Optional[str] = None,
keyfile: Optional[str] = None):
super().__init__(identifier=identifier)
if password is not None and keyfile is not None:
raise SemanticError(
"SshCredential accepts password OR keyfile, not both."
)
if password is None and keyfile is None:
raise SemanticError(
"SshCredential requires one of: password, keyfile."
)
self._password = password
self._keyfile = keyfile
return
A caller who constructs SshCredential(identifier="x", password="pw", keyfile="/path") has misused the API. The
SemanticError fires; the constructor's caller sees the failure;
the caller fixes the misuse. No layer of recovery code in
between gets the chance to swallow it.
ConfigurationError(BaseException) — must bubble in most contexts
class ConfigurationError(BaseException):
"""
The base error object for errors that indicate that there is an issue related
to improper configuration.
"""
ConfigurationError signals configuration problems — a
landscape file that's missing a required field, a credential
identifier that doesn't resolve, a runtime YAML with a
malformed entry. Like SemanticError, it inherits from
BaseException so generic exception handlers don't silently
absorb it.
The semantics are slightly more nuanced than SemanticError's:
-
Library code may catch
ConfigurationErrorto augment the message and re-raise. When a configuration problem surfaces three layers deep in a loader, the layer that knows about the source URI can catch the error, append its context, and re-raise:try: payload = self._parse_yaml(content) except ConfigurationError as err: errmsg = "Error parsing %s: %s" % (self._source_uri, err) raise ConfigurationError(errmsg) from err
The original exception is preserved as the cause (
from err); the new message adds the source URI; the caller receives a more informative error. -
Library code may not catch
ConfigurationErrorto absorb it. When the library can't recover from the configuration problem, the error bubbles. The application's top-level handler reports the configuration issue and exits cleanly.
The two specific subclasses in the package make finer distinctions:
MissingConfigurationError— a required field or file is missing.InvalidConfigurationError— a field is present but holds an unusable value.
Both inherit from ConfigurationError, so the must-bubble
semantics apply to both. Library code that needs to react to
"missing vs invalid" specifically can catch the subclass; library
code that just wants to know "configuration is wrong" catches
the base.
AssertionError — built-in, but with discipline
AssertionError is Python's built-in. The convention treats it
as the condition or state failure in the target under test —
the check that fires when the system isn't behaving as expected.
The discipline:
- Test code raises
AssertionErrorthrough theverification.assert_*family or through bareassertstatements. The error propagates to the test runner, which reports the failure. - Library code does not raise
AssertionErrorfor invariant violations. UseSemanticErrorfor "the caller used the API wrong" orRuntimeError(or a more specific subclass) for "something unexpected happened." ReservingAssertionErrorfor tests keeps the test framework's reporting clean.
The other categories
The package's remaining ~80 exception classes fall into seven
operational categories. Each is a RuntimeError subclass (or
inherits from a more specific built-in like TimeoutError),
which means generic except Exception: handlers do catch
them when appropriate.
Command and process
CommandError— a wrapped subprocess command returned a non-zero exit code. The exception carries thestatus,stdout, andstderrof the failed command, so the handler has the operational context to diagnose.UnExpectedSessionClosureError— an SSH or similar session closed without explicit close.
Resource lifecycle
CheckinError— a resource checkin failed.CheckoutError— a resource checkout failed (chapter 6 of Volume 6 develops this in the landscape context).PublishError— publishing an artifact failed.
Flow control
CancelledError— an operation was cancelled. Three subclasses with finer meaning:TaskingCancelledfor task-level cancellation,TaskingGroupCancelledfor group-level.HaltError— a child flow-control context asks the parent to halt.SkipError— a child flow-control context (typically a test case) asks to be skipped.
Tasking and looping
LooperError— generic looper-pool error.LooperQueueShutdownError— work queued on a shut-down queue, or a worker waiting on an empty shut-down queue.TaskingGroupRuntimeError— runtime error inside a task-group execution.TaskingGroupAssertionError— an assertion failed inside a task group. Inherits fromAssertionError(deliberate exception to the "library code doesn't raise AssertionError" rule because tasking groups are test-running code).
Override / abstract method
-
AbstractMethodError— an abstract method was called without being overridden. Used by abstract base classes when the base provides a "you must override" stub:class Task(FlowNode): @abstractmethod def perform_task(self, environment, parameters, flowstate) -> int: raise AbstractMethodError( "Task->perform_task must be overloaded by derived classes." ) from None
-
NotSupportedError— a method that exists is not supported in the current context (different from "must be overridden"). -
UnSupportedPlatformError— the current platform doesn't support the operation (e.g., a macOS-specific call invoked on Linux). -
UnknownParameterError— an unknown keyword argument was passed.
Timeout
Three subclasses of Python's built-in TimeoutError, each
naming a specific timeout shape:
CompletionTimeout— an operation didn't complete in the allotted time.ConnectionTimeout— a connection attempt didn't succeed in the allotted time.InActivityTimeout— an operation produced no progress for too long (different from "didn't complete" — the operation may still be making progress, but in a different dimension).
Command-line errors
A family of CommandlineError subclasses for argparse-style
parsing problems: BadOptionUsageError, BadParameterError,
MissingArgumentError, MissingOptionError, NoSuchOptionError,
TooManyArgumentsError.
HTTP errors
The largest sub-family — roughly 50 classes — mirroring HTTP
status codes. Three top-level categories
(HttpResponse3xxError, HttpResponse4xxError,
HttpResponse5xxError) each have specific subclasses for the
common status codes (HttpNotFoundError,
HttpUnAuthorizedError, HttpInternalServerError, etc.).
The discipline:
- HTTP-aware code may catch the specific subclass when it has a recovery path (retry on a 503, return cached result on a 404, refresh token on a 401).
- HTTP-aware code may catch the family base (e.g.,
HttpResponse5xxError) when it wants to handle all server-side errors uniformly. - HTTP-unaware code lets them bubble. They are
HTTPExceptionsubclasses; generic exception handlers see them.
The fine-grained vocabulary is what makes per-status-code recovery practical without a regex over the message text.
The six exception-handling rules
Chapter 1 previewed the six rules; chapter 2 mentioned them; this chapter is the canonical statement.
Rule 1: Don't handle exceptions you can't recover from
# Forbidden
try:
result = fetch_remote_value()
except Exception:
pass # silently absorbed — the result is now undefined
The pattern silently produces an undefined value. When the caller eventually uses it, the failure is far from the cause, and the engineer debugging has no breadcrumb. The discipline: don't catch what you can't handle. Let the exception bubble; the caller gets to decide.
Rule 2: Catch and reclassify to add specific contextual information
# Required, when the catching layer has context the inner layer doesn't
try:
payload = self._parse_yaml(content)
except yaml.YAMLError as err:
errmsg = "Failed to parse landscape file %s." % self._source_path
raise InvalidConfigurationError(errmsg) from err
The catching layer knows the source path; the inner layer
doesn't. The catch reclassifies the generic yaml.YAMLError
into a specific InvalidConfigurationError with the path
embedded. The original exception is preserved as __cause__
through from err.
Rule 3: Catch and reclassify to improve code-context in failure reports
# Required, when the catching layer can name *what it was trying to do*
try:
cluster = self._build_cluster(name=name, members=members)
except KeyError as err:
errmsg = "Building cluster %r failed: missing landscape member %s." % (name, err)
raise ConfigurationError(errmsg) from err
A KeyError from deep inside the dispatch tree is hard to
attribute. Reclassifying it at the layer that named the
operation turns the failure into a clear "I was building
cluster X and member Y was missing."
Rule 4: Catch and add extra contextual information
The variant where the original exception type is preserved but the message is enriched:
try:
self._engine.connect()
except ConnectionTimeout as err:
err.add_note("Last successful connection: %s" % self._last_success)
raise
(add_note is Python 3.11+; for earlier Pythons, the same
pattern uses from and a wrapping exception.) The original
exception type is preserved, so callers' type-based handlers
still work; the message gains operational context.
Rule 5: SemanticError must NEVER be caught
The discipline applies to library code, application code, and
test code uniformly. A try: ... except SemanticError: block
anywhere is a violation. The rationale (above): SemanticError
signals API misuse, and absorbing it lets the misuse continue
silently.
The same rule applies to ConfigurationError in most contexts —
the exception should reach the layer that can act on a
configuration problem (typically the application's top-level
handler that emits a clear error message and exits). Library
layers may augment the error and re-raise (rule 2/3) but may
not absorb it.
Rule 6: In all other cases, let exceptions bubble
The default is no catching. When a function doesn't have a
specific recovery path, it doesn't have a try/except. The
exception travels up the stack; some higher layer either has a
recovery or surfaces the failure to the user.
This is the errors-never-silent principle in action. A codebase whose default is "catch and recover" accumulates recovery points that don't actually recover; the failures get absorbed; the bugs ship. A codebase whose default is "let it bubble" produces clear failures that the engineer can diagnose.
Why BaseException for SemanticError and ConfigurationError
Python's exception hierarchy:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ArithmeticError, LookupError, OSError, RuntimeError, ValueError, ...
└── (most user-defined exceptions)
The convention is that Exception is for application errors
that can be caught; BaseException is for system-level events
(SystemExit, KeyboardInterrupt) that should not be caught
by generic except Exception: handlers.
By inheriting SemanticError and ConfigurationError from
BaseException directly, the package opts them into the same
"don't catch generically" treatment. A library wrapper with
except Exception: for legitimate recovery does not swallow a
SemanticError — the caller's API misuse still surfaces.
This is one of the portfolio's most consequential design choices. Most third-party libraries put their custom exceptions in the Exception subtree; the ccai portfolio deliberately lifts the must-bubble ones out. It's a small choice in the class definition, with a large consequence at every catch site across every package.
The vocabulary's role across the portfolio
Every other package in the foundation tier uses
ccai.errors for its exception vocabulary. Specific uses:
- Volume 0 chapter 4 (Interfaces) uses
AbstractMethodErrorto signal that an abstract method's stub was hit because no subclass overrode it. - Volume 0 chapter 5 (Waiting) uses
CompletionTimeout,ConnectionTimeout,InActivityTimeoutfor the three timeout shapes. - Volume 0 chapter 9 (Configuration loading) uses
ConfigurationError,MissingConfigurationError,InvalidConfigurationErrorfor the load-time validation. - Volume 3 (every chapter) uses the vocabulary uniformly for both library-side and application-side error reporting.
The vocabulary is what makes the portfolio interoperate. When two packages from different tiers want to talk about errors, they talk in the shared language. A landscape error in Volume 6 that surfaces during a Volume 3 orchestration run is the same exception type both packages understand.
Summary
ccai.errorsis a small subpackage ofcodecraftai-foundationswith ~80 exception classes organised into seven operational categories plus three special types whose semantics matter universally.SemanticError(BaseException)signals API misuse. Must always bubble. Library code may not catch. Inherits fromBaseExceptionso genericexcept Exception:does not swallow it.ConfigurationError(BaseException)signals configuration problems. Library code may catch only to augment the message and re-raise. SameBaseExceptioninheritance for the same reason.AssertionErroris Python's built-in. The convention treats it as the test-framework's signal; library code does not raise it.- The other 80 exceptions are organised by operational category:
command/process, resource lifecycle, flow control, tasking,
override/abstract, timeout, command-line, HTTP. Each is a
RuntimeError(or specific-built-in) subclass that can be caught when appropriate. - The six exception-handling rules govern when to catch and
when to bubble:
- Don't catch what you can't handle.
- Catch to add contextual information.
- Catch to improve code-context in reports.
- Catch to add operational context to existing errors.
- Never catch
SemanticError. - Default to letting exceptions bubble.
- The
BaseExceptionchoice forSemanticErrorandConfigurationErroris the portfolio's most consequential exception-handling decision. It's small in the class definition; large at every catch site. - The vocabulary is what makes packages interoperate. Two packages from different tiers talk about errors in the shared language.
The next chapter introduces interfaces and contracts — the abstract-base-class machinery that lets packages declare what implementers must provide without specifying how.