Skip to content

Commit 7466fe6

Browse files
committed
refactor: extract HTTP transport into BSBLANTransport collaborator
Move the low-level HTTP concerns out of BSBLAN into a focused BSBLANTransport class in src/bsblan/_transport.py: - _request_with_retry (now request_with_retry on the transport) - _process_response, _build_url, _get_auth, _get_headers - backoff policy and the named giveup function _should_give_up_retry The transport reads the session and firmware version through getter lambdas because both are assigned after BSBLAN construction (__aenter__ and initialize respectively). BSBLAN._request() stays on the client as a stable facade (it is monkeypatched in tests) and delegates to the transport. A __post_init__ method wires up the transport instance. Removed now-unused imports from bsblan.py: backoff, BasicAuth, URL, BSBLANAuthError. Updated test_auth, test_configuration, and test_version_errors to call the moved methods through bsblan._transport; test_backoff_retry is unchanged and exercises the transport through the _request facade. Behavior preserving; no public API change.
1 parent 378c10b commit 7466fe6

5 files changed

Lines changed: 243 additions & 147 deletions

File tree

src/bsblan/_transport.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"""HTTP transport layer for the BSBLAN client.
2+
3+
Owns the low-level concerns of talking to a BSB-LAN device: URL building,
4+
authentication, headers, request execution with exponential-backoff retries,
5+
and firmware-specific response post-processing. The owning client keeps a
6+
stable ``_request`` facade that delegates here.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import asyncio
12+
import logging
13+
from typing import TYPE_CHECKING, Any, cast
14+
15+
import aiohttp
16+
import backoff
17+
from aiohttp.helpers import BasicAuth
18+
from packaging import version as pkg_version
19+
from yarl import URL
20+
21+
from .constants import ErrorMsg
22+
from .exceptions import BSBLANAuthError, BSBLANError
23+
24+
if TYPE_CHECKING:
25+
from collections.abc import Callable, Mapping
26+
27+
from aiohttp.client import ClientSession
28+
29+
from .bsblan import BSBLANConfig
30+
31+
logger = logging.getLogger(__name__)
32+
33+
# HTTP status that must not be retried (resource genuinely absent).
34+
HTTP_NOT_FOUND = 404
35+
# HTTP statuses that indicate authentication failure (not retried).
36+
HTTP_AUTH_STATUSES = (401, 403)
37+
# Firmware version at/after which /JQ responses include a debug `payload` field.
38+
PAYLOAD_FIELD_MIN_VERSION = "5.0.0"
39+
40+
41+
def _should_give_up_retry(error: Exception) -> bool:
42+
"""Return whether a failed request must not be retried.
43+
44+
Args:
45+
error: The exception raised while performing the request.
46+
47+
Returns:
48+
bool: True for HTTP 404 responses, which are not transient.
49+
50+
"""
51+
return (
52+
isinstance(error, aiohttp.ClientResponseError)
53+
and error.status == HTTP_NOT_FOUND
54+
)
55+
56+
57+
class BSBLANTransport:
58+
"""Handle HTTP transport for a BSB-LAN device.
59+
60+
The session and firmware version are read through callables because both
61+
are assigned on the owning client after it is constructed (the session in
62+
``__aenter__`` and the firmware version during ``initialize``).
63+
"""
64+
65+
def __init__(
66+
self,
67+
config: BSBLANConfig,
68+
session_getter: Callable[[], ClientSession | None],
69+
firmware_getter: Callable[[], str | None],
70+
) -> None:
71+
"""Initialize the transport.
72+
73+
Args:
74+
config: Connection configuration (host, port, credentials, timeout).
75+
session_getter: Callable returning the current client session.
76+
firmware_getter: Callable returning the current firmware version.
77+
78+
"""
79+
self._config = config
80+
self._session_getter = session_getter
81+
self._firmware_getter = firmware_getter
82+
83+
@backoff.on_exception(
84+
backoff.expo,
85+
(TimeoutError, aiohttp.ClientError),
86+
max_tries=3,
87+
max_time=30,
88+
giveup=_should_give_up_retry,
89+
logger=logger,
90+
)
91+
async def request_with_retry(
92+
self,
93+
method: str,
94+
base_path: str,
95+
data: dict[str, object] | None,
96+
params: Mapping[str, str | int] | str | None,
97+
) -> dict[str, Any]:
98+
"""Execute an HTTP request with automatic retries.
99+
100+
Decorated with backoff for automatic retries on transient failures.
101+
102+
Args:
103+
method: The HTTP method to use.
104+
base_path: The base path for the URL.
105+
data: The data to send in the request body.
106+
params: The query parameters to include.
107+
108+
Returns:
109+
dict[str, Any]: The JSON response from the BSBLAN device.
110+
111+
Raises:
112+
BSBLANError: If the session is missing or the response is invalid.
113+
BSBLANAuthError: If authentication fails (401/403, not retried).
114+
115+
"""
116+
session = self._session_getter()
117+
if session is None:
118+
raise BSBLANError(ErrorMsg.SESSION_NOT_INITIALIZED)
119+
url = self._build_url(base_path)
120+
auth = self._get_auth()
121+
headers = self._get_headers()
122+
123+
try:
124+
async with asyncio.timeout(self._config.request_timeout):
125+
async with session.request(
126+
method,
127+
url,
128+
auth=auth,
129+
params=params,
130+
json=data,
131+
headers=headers,
132+
) as response:
133+
response.raise_for_status()
134+
response_data = cast("dict[str, Any]", await response.json())
135+
return self._process_response(response_data, base_path)
136+
except aiohttp.ClientResponseError as e:
137+
if e.status in HTTP_AUTH_STATUSES:
138+
raise BSBLANAuthError from e
139+
raise
140+
except (ValueError, UnicodeDecodeError) as e:
141+
# Handle JSON decode errors and other parsing issues
142+
msg = ErrorMsg.INVALID_RESPONSE.format(e)
143+
raise BSBLANError(msg) from e
144+
145+
def _process_response(
146+
self, response_data: dict[str, Any], base_path: str
147+
) -> dict[str, Any]:
148+
"""Process response data based on firmware version.
149+
150+
BSB-LAN 5.0+ includes an additional 'payload' field in /JQ responses
151+
that needs to be handled for compatibility.
152+
153+
Args:
154+
response_data: Raw response data from BSB-LAN.
155+
base_path: The API endpoint that was called.
156+
157+
Returns:
158+
Processed response data compatible with existing code.
159+
160+
"""
161+
# For non-JQ endpoints, return response as-is
162+
if base_path != "/JQ":
163+
return response_data
164+
165+
# Check if we have a firmware version to determine processing
166+
firmware_version = self._firmware_getter()
167+
if not firmware_version:
168+
return response_data
169+
170+
# For BSB-LAN 5.0+, remove 'payload' field if present (debugging only)
171+
version = pkg_version.parse(firmware_version)
172+
if (
173+
version >= pkg_version.parse(PAYLOAD_FIELD_MIN_VERSION)
174+
and "payload" in response_data
175+
):
176+
return {k: v for k, v in response_data.items() if k != "payload"}
177+
178+
return response_data
179+
180+
def _build_url(self, base_path: str) -> URL:
181+
"""Build the URL for the request.
182+
183+
Args:
184+
base_path (str): The base path for the URL.
185+
186+
Returns:
187+
URL: The constructed URL.
188+
189+
"""
190+
if self._config.passkey:
191+
base_path = f"/{self._config.passkey}{base_path}"
192+
return URL.build(
193+
scheme="http",
194+
host=self._config.host,
195+
port=self._config.port,
196+
path=base_path,
197+
)
198+
199+
def _get_auth(self) -> BasicAuth | None:
200+
"""Get the authentication for the request.
201+
202+
Returns:
203+
BasicAuth | None: The authentication object or None if no
204+
authentication is required.
205+
206+
"""
207+
if self._config.username and self._config.password:
208+
return BasicAuth(self._config.username, self._config.password)
209+
return None
210+
211+
def _get_headers(self) -> dict[str, str]:
212+
"""Get the headers for the request.
213+
214+
Returns:
215+
dict[str, str]: The headers for the request.
216+
217+
"""
218+
return {
219+
"User-Agent": f"PythonBSBLAN/{self._firmware_getter()}",
220+
"Accept": "application/json, */*",
221+
}

0 commit comments

Comments
 (0)