Error Taxonomy¶
Complete catalog of all exceptions raised by the cosalette framework. Errors are grouped by when they occur: during registration (app setup), at runtime (after bootstrap), or through the error publishing pipeline.
Concept vs Reference
For the design rationale behind error handling, see Error Handling (concept). For the error payload JSON schema and MQTT topics, see Payload Schemas. This page catalogs every framework-raised exception with its trigger, message, and fix.
Registration-Time Errors¶
These exceptions are raised before the app starts running — during device
registration, decorator application, or App() construction. They indicate
a programming mistake in your setup code.
TypeError¶
Registration TypeError exceptions mean the framework received a value of
the wrong type, or a callback/handler violates a structural requirement.
Decorator Parentheses¶
Raised when @app.device or @app.command is used without parentheses.
| Location | Message |
|---|---|
app.device() |
Use @app.device(), not @app.device (parentheses required) |
app.command() |
Use @app.command(), not @app.command (parentheses required) |
Cause: Python calls the decorator with the function as the first argument when parentheses are missing, which is never the intended use.
Fix: Always use parentheses, even with no arguments:
# Wrong
@app.device
async def my_device(ctx: DeviceContext) -> dict[str, object]:
...
# Correct
@app.device()
async def my_device(ctx: DeviceContext) -> dict[str, object]:
...
Async init Callback¶
Raised when the init= parameter of @app.device() receives an async function
or a callable with an async __call__.
| Location | Message |
|---|---|
app.device(init=...) |
init= must be a synchronous callable, not async. Use a regular function or a class with __call__. |
app.device(init=...) |
init= must be a synchronous callable, not async. The __call__ method is a coroutine function. |
Cause: The init= callback runs during synchronous bootstrap. Async functions
cannot be awaited in that phase.
Fix: Use a regular synchronous function:
# Wrong
async def setup_sensor():
return SensorClient()
@app.device(init=setup_sensor) # TypeError!
async def sensor(ctx: DeviceContext) -> dict[str, object]:
...
# Correct
def setup_sensor():
return SensorClient()
@app.device(init=setup_sensor)
async def sensor(ctx: DeviceContext) -> dict[str, object]:
...
init Result Shadows Injectable¶
Raised when the init= callback returns a type that the framework already
provides via dependency injection (e.g. AppContext, MqttPort).
| Location | Message |
|---|---|
app.device(init=...) |
init= callback returned {type}!, which shadows a framework-provided type. Use a wrapper class or a different type. |
Fix: Wrap the value in a domain-specific type instead of returning a framework type directly.
Bool Parameters (Type Guard)¶
Because bool is a subclass of int (and float), numeric constructor
parameters explicitly reject booleans to catch accidental True/False
arguments.
| Location | Parameter | Message |
|---|---|---|
Pt1Filter |
tau |
tau must be a number, got bool: {tau!r} |
Pt1Filter |
dt |
dt must be a number, got bool: {dt!r} |
MedianFilter |
window |
window must be an int, got bool: {window!r} |
OneEuroFilter |
min_cutoff, beta, d_cutoff, dt |
{name} must be a number, got bool: {val!r} |
OnChange |
per-field threshold | Threshold for '{field}' must be a number, got bool |
OnChange |
global threshold | Threshold must be a number, got bool |
Cause: isinstance(True, int) is True in Python, so without an
explicit guard, Pt1Filter(tau=True) would silently pass as tau=1.
Fix: Pass a numeric literal:
Non-Int window¶
Raised when MedianFilter(window=...) receives a value that is not
an int (and not a bool).
| Location | Message |
|---|---|
MedianFilter |
window must be an int, got {type}: {window!r} |
Fix: Pass an integer: MedianFilter(window=5).
Handler Annotation Errors¶
Raised when a device or command handler has parameters that the injection system cannot resolve.
| Location | Message |
|---|---|
| Handler injection | Parameter '{name}' of handler {qualname!r} has no type annotation... |
| Handler injection | Parameter '{name}' of handler {qualname!r} has unsupported kind... |
| Handler injection | Parameter '{name}' of handler {qualname!r} has annotation {annotation!r} which is not a type... |
Cause: The injection system resolves handler parameters by their type
annotations. Every parameter must have a concrete type annotation — no
*args, **kwargs, positional-only, or non-type annotations.
Fix: Annotate every parameter with a concrete type:
# Wrong — missing annotation
@app.device()
async def sensor(ctx): # TypeError!
...
# Wrong — *args
@app.device()
async def sensor(*args: DeviceContext) -> dict[str, object]: # TypeError!
...
# Correct
@app.device()
async def sensor(ctx: DeviceContext) -> dict[str, object]:
...
Adapter __aenter__ Not Callable¶
Raised when an adapter has an __aenter__ attribute that is not callable.
| Location | Message |
|---|---|
| Adapter lifecycle | Adapter {adapter!r} has __aenter__ but it's not callable |
Fix: Ensure the adapter is a proper async context manager with a
callable __aenter__ method.
Unresolved Interval¶
Raised when a telemetry device's interval is still a callable at runtime,
meaning resolve_intervals() was never called during bootstrap.
| Location | Message |
|---|---|
| Telemetry runner | Interval for {name!r} has not been resolved (still a callable). Was resolve_intervals() called? |
Cause: This is an internal consistency error — the framework should resolve deferred intervals during bootstrap. If you see this, it may indicate a framework bug or manual misuse of internal APIs.
ValueError¶
Registration ValueError exceptions indicate a value that is the right
type but violates a constraint (negative interval, duplicate name, etc.).
Negative or Zero Intervals¶
Raised when a time interval is not positive.
| Location | Parameter | Message |
|---|---|---|
App() |
heartbeat_interval |
heartbeat_interval must be positive, got {value} |
app.device() |
interval |
Telemetry interval must be positive, got {interval} |
| Bootstrap wiring | resolved interval | Telemetry interval for {name!r} must be positive, got {resolved} |
Fix: Pass a positive numeric value:
# Wrong
app = App(heartbeat_interval=-5) # ValueError
app = App(heartbeat_interval=0) # ValueError
# Correct
app = App(heartbeat_interval=30)
Duplicate Registration¶
Raised when registering a device name or handler that already exists.
| Location | Message |
|---|---|
app.device() / app.command() |
Device name '{name}' is already registered |
app.device() / app.command() |
Only one root device (unnamed) is allowed per app |
app.device() / app.command() |
Cannot share name '{name}' between root and named registrations — MQTT topic namespaces would conflict |
| Command router | Handler already registered for device '{device_name}' |
| Command router | Root handler already registered |
Cause: Each device name must be unique within an app (see ADR-019 for scoped name uniqueness). The root device (unnamed) is limited to one.
Fix: Use distinct names for each device:
@app.device(name="temperature")
async def temp_device(ctx: DeviceContext) -> dict[str, object]:
...
@app.device(name="humidity") # Different name
async def humidity_device(ctx: DeviceContext) -> dict[str, object]:
...
Duplicate Adapter¶
Raised when registering a second adapter for the same port type.
| Location | Message |
|---|---|
app.adapter() |
Adapter already registered for {port_type!r} |
Fix: Register only one adapter per port type.
Invalid Adapter Tuple¶
Raised when the adapters= dict value is a tuple that is not a 2-tuple.
| Location | Message |
|---|---|
App(adapters=...) |
adapters value for {port_type!r} must be an impl or (impl, dry_run) 2-tuple, got {len}-tuple |
Fix: Pass either a single adapter instance or a (impl, dry_run) pair:
# Single adapter
app = App(adapters={MqttPort: my_mqtt_client})
# Adapter + dry-run pair
app = App(adapters={MqttPort: (my_mqtt_client, null_mqtt_client)})
Empty Group Name¶
Raised when a coalescing group name is an empty string.
| Location | Message |
|---|---|
app.device(group=...) |
group must be non-empty |
app.command(group=...) |
group must be non-empty |
Fix: Pass a non-empty string for the group parameter.
Persist Without Store¶
Raised when persist=True is set but no store= backend was provided
to App().
| Location | Message |
|---|---|
app.device(persist=True) |
persist= requires a store= backend on the App. Pass store=MemoryStore() (or another Store) to App(). |
app.command(persist=True) |
persist= requires a store= backend on the App. Pass store=MemoryStore() (or another Store) to App(). |
Fix: Pass a store backend when constructing the app:
from cosalette import App, MemoryStore
app = App(store=MemoryStore())
@app.device(persist=True)
async def sensor(ctx: DeviceContext) -> dict[str, object]:
...
Filter and Strategy Parameters¶
Numeric parameters on filters and strategies must be within valid ranges.
| Component | Parameter | Constraint | Message |
|---|---|---|---|
Pt1Filter |
tau |
> 0 |
tau must be positive, got {tau!r} |
Pt1Filter |
dt |
> 0 |
dt must be positive, got {dt!r} |
MedianFilter |
window |
>= 1 |
window must be >= 1, got {window!r} |
OneEuroFilter |
min_cutoff |
> 0 |
min_cutoff must be positive, got {value!r} |
OneEuroFilter |
beta |
>= 0 |
beta must be non-negative, got {value!r} |
OneEuroFilter |
d_cutoff |
> 0 |
d_cutoff must be positive, got {value!r} |
OneEuroFilter |
dt |
> 0 |
dt must be positive, got {value!r} |
OnChange |
per-field threshold | >= 0 |
Threshold for '{field}' must be non-negative, got {value} |
OnChange |
global threshold | >= 0 |
Threshold must be non-negative, got {threshold} |
Every |
seconds |
> 0 |
'seconds' must be positive |
Every |
n |
> 0 |
'n' must be positive |
Strategy Mutual Exclusion¶
Every() requires exactly one of seconds or n, not both and not neither.
| Location | Message |
|---|---|
Every() |
Specify exactly one of 'seconds' or 'n', not both |
Every() |
Specify exactly one of 'seconds' or 'n' |
Fix:
# Wrong
Every(seconds=5, n=10) # ValueError — both specified
Every() # ValueError — neither specified
# Correct
Every(seconds=5)
Every(n=10)
Composite Policy Children¶
AnySavePolicy and AllSavePolicy require at least one child policy.
| Location | Message |
|---|---|
AnySavePolicy() |
AnySavePolicy requires at least one child policy |
AllSavePolicy() |
AllSavePolicy requires at least one child policy |
Fix: Pass at least one child policy to the composite.
Composite Strategy Children¶
AnyStrategy and AllStrategy require at least one child strategy.
| Location | Message |
|---|---|
AnyStrategy() |
AnyStrategy requires at least one child strategy |
AllStrategy() |
AllStrategy requires at least one child strategy |
Fix: Pass at least one child strategy to the composite.
Import Path Format¶
Raised when an import path string does not follow the module.path:attr_name
convention.
| Location | Message |
|---|---|
import_string() |
Expected 'module.path:attr_name', got {dotted_path!r} |
Fix: Use the colon-separated format: "mypackage.module:MyClass".
Runtime Errors¶
These exceptions are raised after the app has started — during bootstrap completion, MQTT operations, or store access.
RuntimeError¶
Settings Unavailable¶
Raised when the settings model cannot be instantiated, typically because required environment variables are missing.
| Location | Message |
|---|---|
AppContext |
Settings could not be instantiated at construction time (missing required fields?). Ensure required environment variables are set, or use app.cli() with --env-file. |
Fix: Set the required environment variables before running the app,
or use app.cli() with --env-file to load them from a file.
MQTT Not Connected¶
Raised when attempting to publish or subscribe but the MQTT client is not connected.
| Location | Message |
|---|---|
MqttClient |
MqttClient is not connected |
Cause: Publishing was attempted before the MQTT client connected, or
after it disconnected. The framework manages connection lifecycle
automatically — this typically indicates use of the MqttClient outside
the normal lifecycle.
aiomqtt Not Installed¶
Raised when MqttClient is instantiated but the aiomqtt package is not
available.
| Location | Message |
|---|---|
MqttClient |
aiomqtt is required to use MqttClient |
Fix: Install the MQTT extra: pip install cosalette[mqtt] or
uv add cosalette[mqtt].
Store Not Loaded¶
Raised when accessing DeviceStore data before load() has been called.
| Location | Message |
|---|---|
DeviceStore |
DeviceStore.load() must be called before accessing data |
Cause: The framework calls load() during bootstrap. This error
indicates manual use of DeviceStore outside the normal lifecycle,
or a framework bug.
Store Not Set¶
Raised internally when create_device_store() is called but no store
backend was configured.
| Location | Message |
|---|---|
| Device bootstrap | store must be set before calling create_device_store |
LookupError¶
Adapter Not Found¶
Raised when requesting an adapter for a port type that was never registered.
| Location | Message |
|---|---|
AppContext.adapter() |
No adapter registered for {port_type!r} |
Fix: Register the required adapter when constructing the app:
CLI Errors¶
These exceptions are raised by the CLI layer (Typer) when the user provides invalid command-line arguments.
typer.BadParameter¶
| Location | Message |
|---|---|
app.cli() |
Invalid log level '{value}'. Choose from: {choices} |
app.cli() |
Invalid log format '{value}'. Choose from: {choices} |
SystemExit¶
The CLI exits with code 1 (EXIT_CONFIG_ERROR) when the configuration
model raises a validation error (e.g. from pydantic).
Error Publishing Pipeline¶
The framework includes a built-in error publishing system for reporting runtime errors via MQTT. This section summarizes the pipeline — for full details, see Error Handling (concept) and Payload Schemas.
Pipeline Flow¶
Exception raised in device function
↓
build_error_payload(error, error_type_map=..., device=...)
↓
ErrorPayload(error_type, message, device, timestamp, details)
↓
ErrorPublisher.publish()
↓
MQTT: {prefix}/error (global, always)
MQTT: {prefix}/{device}/error (per-device, when device known)
error_type_map Pattern¶
The error_type_map is a dict[type[Exception], str] that maps
exact exception classes (no subclass matching) to machine-readable
error_type strings. Unmapped exceptions produce "error" as the type.
error_type_map: dict[type[Exception], str] = {
InvalidCommandError: "invalid_command",
TimeoutError: "timeout",
ConnectionError: "connection_lost",
}
Publication Behaviour¶
| Property | Value |
|---|---|
| QoS | 1 (at-least-once) |
| Retained | No — errors are events, not state |
| Failure handling | Fire-and-forget — logged but never propagated |
| Output | Dual — logged at WARNING + published to MQTT |
Topic Layout¶
| Topic | Description |
|---|---|
{prefix}/error |
Global error topic — receives all errors |
{prefix}/{device}/error |
Per-device error topic — when device name is known |
Root devices (unnamed) only publish to the global topic to avoid duplicating the same error on both topics.
See Also¶
- Error Handling (concept) — design principles and rationale
- Payload Schemas — JSON schema for
ErrorPayload - Map Custom Error Types (guide) — how to use
build_error_payload()with custom domain exceptions - ADR-011 — architecture decision record