Skip to content

Commit dafc0a2

Browse files
committed
Add new exception types for unsupported schedules and malformed responses
1 parent 3b8ebad commit dafc0a2

6 files changed

Lines changed: 290 additions & 0 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
---
2+
schema: spec-driven
3+
created: 2026-07-12
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
## Context
2+
3+
`python-bsblan` currently exposes a single generic `BSBLANError` for a wide
4+
range of failure modes. Two of them are semantically opposite from a retry
5+
standpoint:
6+
7+
- **Unsupported schedule** — the device does not expose the requested
8+
time-program parameters. In `_schedules.heating_schedule()` this raises
9+
`BSBLANError(ErrorMsg.NO_HEATING_SCHEDULE_PARAMS)`, and in
10+
`_hot_water._get_filtered_data()` it raises `BSBLANError(error_msg)` for the
11+
hot-water schedule group. This is a **permanent** condition.
12+
- **Malformed response** — the transport cannot decode or JSON-parse the body.
13+
In `_transport.request_with_retry()` this is caught from `ValueError` /
14+
`UnicodeDecodeError` and re-raised as
15+
`BSBLANError(ErrorMsg.INVALID_RESPONSE.format(e))`. This is a **transient**
16+
condition.
17+
18+
Because both surface as `BSBLANError`, a downstream integration (Home Assistant)
19+
cannot decide whether to retry. It must either stop retrying (losing recovery
20+
from transient malformed responses) or retry forever (uselessly hammering a
21+
device that will never support a schedule after a write).
22+
23+
The library's own `backoff` decorator only retries `TimeoutError` and
24+
`aiohttp.ClientError`, so `BSBLANError` is never retried inside the library; the
25+
retry decision lives in the consumer. The fix is therefore an additive exception
26+
taxonomy, not a change to the transport retry policy.
27+
28+
## Goals / Non-Goals
29+
30+
**Goals:**
31+
- Let callers distinguish permanent unsupported-feature failures from transient
32+
malformed-response failures by `except` type.
33+
- Keep the change fully backward compatible with existing `except BSBLANError`
34+
handlers.
35+
- Reuse existing `ErrorMsg` strings; no message wording changes.
36+
37+
**Non-Goals:**
38+
- Changing the transport-level `backoff` retry policy or which HTTP statuses are
39+
retried (`client-retry-policy` is unchanged).
40+
- Adding automatic retry logic inside the library for schedules.
41+
- Reclassifying other existing `BSBLANError` raise sites beyond schedule reads
42+
and malformed-response parsing.
43+
44+
## Decisions
45+
46+
**Decision: Two new subclasses of `BSBLANError`.**
47+
Add `BSBLANUnsupportedFeatureError` (permanent) and
48+
`BSBLANMalformedResponseError` (transient), both subclassing `BSBLANError`.
49+
Subclassing preserves backward compatibility: any existing `except BSBLANError`
50+
keeps catching them.
51+
- *Alternative considered:* a single new error with a `retryable: bool`
52+
attribute. Rejected — attribute inspection is less idiomatic than `except`
53+
type matching and easier to get wrong at call sites.
54+
- *Alternative considered:* schedule-specific name (e.g.
55+
`BSBLANUnsupportedScheduleError`). Chose the more general
56+
`BSBLANUnsupportedFeatureError` because the same permanent semantics apply to
57+
both heating and hot-water schedule reads and can extend to other
58+
unsupported-parameter cases later.
59+
60+
**Decision: Raise the permanent error at the schedule-read guard sites.**
61+
Replace the bare `BSBLANError(...)` at
62+
`_schedules.heating_schedule()` (no mapped data) and the hot-water schedule
63+
branch in `_hot_water._get_filtered_data()` with
64+
`BSBLANUnsupportedFeatureError`, keeping the existing `ErrorMsg` text. The
65+
hot-water helper is shared by state/config/schedule reads, so scope the new
66+
error to the schedule group only (via `group_name == "schedule"`) to avoid
67+
reclassifying unrelated state/config failures.
68+
69+
**Decision: Raise the transient error at the transport parse site.**
70+
In `_transport.request_with_retry()`, replace the
71+
`raise BSBLANError(msg)` in the `(ValueError, UnicodeDecodeError)` handler with
72+
`raise BSBLANMalformedResponseError(msg)`, reusing
73+
`ErrorMsg.INVALID_RESPONSE`.
74+
75+
**Decision: Export the new exceptions from the package.**
76+
Add both classes to `bsblan.exceptions` and to the package `__all__` /
77+
re-exports so consumers can import them from the top-level `bsblan` namespace.
78+
79+
## Risks / Trade-offs
80+
81+
- **[Consumers catching the narrow generic message string]** → Mitigation:
82+
messages are unchanged; only the concrete type is narrower. Broad
83+
`except BSBLANError` still works.
84+
- **[Over-scoping the hot-water helper]** → Mitigation: gate the new error on the
85+
schedule group so state/config reads keep raising the existing generic error
86+
and existing tests stay green.
87+
- **[Missed raise site]** → Mitigation: grep for `NO_HEATING_SCHEDULE_PARAMS`,
88+
the hot-water schedule `error_msg`, and `INVALID_RESPONSE` to confirm all
89+
targeted sites are updated; add tests asserting the concrete types.
90+
91+
## Migration Plan
92+
93+
Additive, no migration required. Release as a minor version. Consumers may
94+
optionally add narrower `except BSBLANUnsupportedFeatureError` /
95+
`except BSBLANMalformedResponseError` handlers; existing code is unaffected.
96+
97+
## Open Questions
98+
99+
- Final class names: `BSBLANUnsupportedFeatureError` vs
100+
`BSBLANUnsupportedScheduleError`. Proposed the broader name; confirm during
101+
review.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
## Why
2+
3+
In python-bsblan 6.1.6 both an *unsupported schedule* (a device that will never
4+
return the requested time-program parameters) and a *malformed response* (a
5+
transient decode/parse failure) are surfaced as the same generic `BSBLANError`.
6+
Downstream integrations such as Home Assistant cannot tell them apart by
7+
exception type, so they must choose between stopping retries on a transient
8+
malformed response or retrying an unsupported schedule indefinitely after a
9+
write. A dedicated exception taxonomy is needed at the library level to make the
10+
permanent-vs-transient distinction explicit.
11+
12+
## What Changes
13+
14+
- Add a dedicated **permanent** exception `BSBLANUnsupportedFeatureError`
15+
(subclass of `BSBLANError`) raised when a device does not expose the requested
16+
schedule parameters, so callers know retrying will never succeed.
17+
- Add a dedicated **transient** exception `BSBLANMalformedResponseError`
18+
(subclass of `BSBLANError`) raised when a response body cannot be decoded or
19+
parsed as valid JSON, signalling that a retry may succeed.
20+
- Raise `BSBLANUnsupportedFeatureError` in the heating- and hot-water-schedule
21+
read paths where a bare `BSBLANError` is currently raised for absent schedule
22+
parameters.
23+
- Raise `BSBLANMalformedResponseError` in the transport layer where invalid
24+
JSON / decode errors are currently wrapped in a bare `BSBLANError`.
25+
- Both new exceptions subclass `BSBLANError`, so existing `except BSBLANError`
26+
handlers keep working (non-breaking).
27+
28+
## Capabilities
29+
30+
### New Capabilities
31+
- `error-classification`: A library exception taxonomy that distinguishes
32+
permanent unsupported-feature failures from transient malformed-response
33+
failures, letting callers make correct retry decisions.
34+
35+
### Modified Capabilities
36+
<!-- No existing spec's requirements change; client-retry-policy behavior is unaffected. -->
37+
38+
## Impact
39+
40+
- `src/bsblan/exceptions.py`: two new exception classes.
41+
- `src/bsblan/_schedules.py` and `src/bsblan/_hot_water.py`: raise the new
42+
unsupported-feature error for absent schedule parameters.
43+
- `src/bsblan/_transport.py`: raise the new malformed-response error for
44+
decode/parse failures.
45+
- `src/bsblan/constants.py`: no new messages required; existing `ErrorMsg`
46+
entries are reused.
47+
- `tests/`: new coverage asserting the specific exception types are raised.
48+
- Public API: additive only. Consumers relying on `BSBLANError` are unaffected;
49+
consumers can opt into finer-grained handling.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Unsupported schedules raise a permanent error
4+
5+
The client SHALL raise `BSBLANUnsupportedFeatureError` (a subclass of
6+
`BSBLANError`) when a schedule read cannot proceed because the device exposes no
7+
schedule parameters for the requested feature. This error MUST signal a
8+
permanent condition so callers know that retrying the same request will never
9+
succeed.
10+
11+
#### Scenario: Heating schedule parameters absent
12+
13+
- **WHEN** `heating_schedule()` is called for a circuit whose time-program
14+
parameters are not present in the device response (no schedule parameters can
15+
be mapped)
16+
- **THEN** the client raises `BSBLANUnsupportedFeatureError`
17+
- **AND** the raised exception is also an instance of `BSBLANError`
18+
19+
#### Scenario: Hot water schedule parameters absent
20+
21+
- **WHEN** `schedule()` is called on a device that exposes no hot-water
22+
time-program parameters after include filtering
23+
- **THEN** the client raises `BSBLANUnsupportedFeatureError`
24+
- **AND** the raised exception is also an instance of `BSBLANError`
25+
26+
### Requirement: Malformed responses raise a transient error
27+
28+
The client SHALL raise `BSBLANMalformedResponseError` (a subclass of
29+
`BSBLANError`) when a device response body cannot be decoded or parsed as valid
30+
JSON. This error MUST signal a transient condition so callers know that a retry
31+
may succeed.
32+
33+
#### Scenario: Response body is not valid JSON
34+
35+
- **WHEN** a request receives an HTTP `200` response whose body cannot be parsed
36+
as JSON
37+
- **THEN** the client raises `BSBLANMalformedResponseError`
38+
- **AND** the raised exception is also an instance of `BSBLANError`
39+
40+
### Requirement: New exceptions preserve backward compatibility
41+
42+
Both `BSBLANUnsupportedFeatureError` and `BSBLANMalformedResponseError` SHALL
43+
subclass `BSBLANError` so that existing `except BSBLANError` handlers continue to
44+
catch them without modification.
45+
46+
#### Scenario: Existing broad handler still catches new errors
47+
48+
- **WHEN** code catches `BSBLANError` around a call that raises either
49+
`BSBLANUnsupportedFeatureError` or `BSBLANMalformedResponseError`
50+
- **THEN** the exception is caught by the `BSBLANError` handler
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
## 1. Exceptions
2+
3+
- [x] 1.1 Add `BSBLANUnsupportedFeatureError(BSBLANError)` to `src/bsblan/exceptions.py` with a docstring describing it as a permanent (non-retryable) condition.
4+
- [x] 1.2 Add `BSBLANMalformedResponseError(BSBLANError)` to `src/bsblan/exceptions.py` with a docstring describing it as a transient (retryable) condition.
5+
- [x] 1.3 Import and re-export both new exceptions in `src/bsblan/__init__.py` and add them to `__all__`.
6+
7+
## 2. Schedule read paths (permanent error)
8+
9+
- [x] 2.1 In `src/bsblan/_schedules.py` `heating_schedule()`, raise `BSBLANUnsupportedFeatureError(ErrorMsg.NO_HEATING_SCHEDULE_PARAMS)` when no schedule parameters are mapped (replace the bare `BSBLANError`).
10+
- [x] 2.2 In `src/bsblan/_hot_water.py` `fetch_data()`, raise `BSBLANUnsupportedFeatureError(error_msg)` when the schedule group (`group_name == "schedule"`) has no filtered params, keeping other groups on the existing generic error.
11+
- [x] 2.3 Update the `Raises:` docstrings on the affected schedule methods to reference the new exception type.
12+
13+
## 3. Transport parse path (transient error)
14+
15+
- [x] 3.1 In `src/bsblan/_transport.py` `request_with_retry()`, raise `BSBLANMalformedResponseError(msg)` from the `(ValueError, UnicodeDecodeError)` handler (replace the bare `BSBLANError`), reusing `ErrorMsg.INVALID_RESPONSE`.
16+
- [x] 3.2 Update the `Raises:` docstring on `request_with_retry()` to reference the new exception type.
17+
18+
## 4. Tests
19+
20+
- [x] 4.1 Add a test asserting `heating_schedule()` raises `BSBLANUnsupportedFeatureError` when schedule params are absent, and that it is also a `BSBLANError`.
21+
- [x] 4.2 Add a test asserting hot-water `schedule()` raises `BSBLANUnsupportedFeatureError` when schedule params are absent.
22+
- [x] 4.3 Add a test asserting a non-JSON / undecodable response raises `BSBLANMalformedResponseError` and that it is also a `BSBLANError`.
23+
- [x] 4.4 Verify existing state/config hot-water tests still raise the generic `BSBLANError` (no regression from the group gating).
24+
25+
## 5. Documentation & validation
26+
27+
- [x] 5.1 Document the two new exceptions in `docs/api/exceptions.md`.
28+
- [x] 5.2 Run `uv run prek run --all-files` and `uv run pytest --cov=src/bsblan --cov-report=term-missing`; ensure total coverage stays >= 95% and patch coverage is 100%.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# error-classification
2+
3+
## Purpose
4+
5+
A library exception taxonomy that distinguishes permanent unsupported-feature
6+
failures from transient malformed-response failures, letting callers make
7+
correct retry decisions. Both new exceptions subclass `BSBLANError` so existing
8+
broad handlers keep working.
9+
10+
## Requirements
11+
12+
### Requirement: Unsupported schedules raise a permanent error
13+
14+
The client SHALL raise `BSBLANUnsupportedFeatureError` (a subclass of
15+
`BSBLANError`) when a schedule read cannot proceed because the device exposes no
16+
schedule parameters for the requested feature. This error MUST signal a
17+
permanent condition so callers know that retrying the same request will never
18+
succeed.
19+
20+
#### Scenario: Heating schedule parameters absent
21+
22+
- **WHEN** `heating_schedule()` is called for a circuit whose time-program
23+
parameters are not present in the device response (no schedule parameters can
24+
be mapped)
25+
- **THEN** the client raises `BSBLANUnsupportedFeatureError`
26+
- **AND** the raised exception is also an instance of `BSBLANError`
27+
28+
#### Scenario: Hot water schedule parameters absent
29+
30+
- **WHEN** `schedule()` is called on a device that exposes no hot-water
31+
time-program parameters after include filtering
32+
- **THEN** the client raises `BSBLANUnsupportedFeatureError`
33+
- **AND** the raised exception is also an instance of `BSBLANError`
34+
35+
### Requirement: Malformed responses raise a transient error
36+
37+
The client SHALL raise `BSBLANMalformedResponseError` (a subclass of
38+
`BSBLANError`) when a device response body cannot be decoded or parsed as valid
39+
JSON. This error MUST signal a transient condition so callers know that a retry
40+
may succeed.
41+
42+
#### Scenario: Response body is not valid JSON
43+
44+
- **WHEN** a request receives an HTTP `200` response whose body cannot be parsed
45+
as JSON
46+
- **THEN** the client raises `BSBLANMalformedResponseError`
47+
- **AND** the raised exception is also an instance of `BSBLANError`
48+
49+
### Requirement: New exceptions preserve backward compatibility
50+
51+
Both `BSBLANUnsupportedFeatureError` and `BSBLANMalformedResponseError` SHALL
52+
subclass `BSBLANError` so that existing `except BSBLANError` handlers continue to
53+
catch them without modification.
54+
55+
#### Scenario: Existing broad handler still catches new errors
56+
57+
- **WHEN** code catches `BSBLANError` around a call that raises either
58+
`BSBLANUnsupportedFeatureError` or `BSBLANMalformedResponseError`
59+
- **THEN** the exception is caught by the `BSBLANError` handler

0 commit comments

Comments
 (0)