Skip to content

Commit 95759d5

Browse files
fix(ERR-03): narrow retry exception catches to httpx.HTTPError
Replace bare `except Exception` with `except httpx.HTTPError` in both sync and async session retry loops. Transport-level failures are the only exceptions that should trigger retries; programming errors now propagate immediately. Also fixes FakeResponse lambda signature bug in async session and updates tests to use httpx.ConnectError instead of bare Exception. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2908911 commit 95759d5

3 files changed

Lines changed: 19 additions & 38 deletions

File tree

meraki/session/async_.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,7 @@ def __init__(
3737
# Build headers dict
3838
headers = self._build_headers()
3939
# Async user-agent prefix
40-
headers["User-Agent"] = f"python-meraki/aio-{self._version} " + validate_user_agent(
41-
self._be_geo_id, self._caller
42-
)
40+
headers["User-Agent"] = f"python-meraki/aio-{self._version} " + validate_user_agent(self._be_geo_id, self._caller)
4341

4442
# Build client config (per D-02: Limits replaces Semaphore, per D-06: proxy passthrough)
4543
client_kwargs: Dict[str, Any] = {
@@ -128,7 +126,7 @@ async def request(self, metadata: Dict[str, Any], method: str, url: str, **kwarg
128126
if self._logger:
129127
self._logger.info(f"{method} {abs_url}")
130128
response = await self._send_request(method, abs_url, **kwargs)
131-
except Exception as e:
129+
except httpx.HTTPError as e:
132130
if self._logger:
133131
self._logger.warning(f"{tag}, {operation} - {e}, retrying in 1 second")
134132
await self._sleep(1)
@@ -139,7 +137,7 @@ async def request(self, metadata: Dict[str, Any], method: str, url: str, **kwarg
139137
type(
140138
"FakeResponse",
141139
(),
142-
{"status_code": 503, "reason_phrase": str(e), "json": lambda: {}, "content": b""},
140+
{"status_code": 503, "reason_phrase": str(e), "json": lambda self: {}, "content": b""},
143141
)(),
144142
)
145143
continue

meraki/session/base.py

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44

55
import json
66
import random
7-
import urllib.parse
87
from abc import ABC, abstractmethod
9-
from typing import TYPE_CHECKING, Any, Dict, Optional
8+
from typing import Any, Dict, Optional
109

1110
from meraki._version import __version__
1211
from meraki.common import (
@@ -32,12 +31,11 @@
3231
USE_ITERATOR_FOR_GET_PAGES,
3332
WAIT_ON_RATE_LIMIT,
3433
)
34+
import httpx
35+
3536
from meraki.exceptions import APIError, APIResponseError
3637
from meraki.response_handler import handle_3xx
3738

38-
if TYPE_CHECKING:
39-
import httpx
40-
4139

4240
class SessionBase(ABC):
4341
"""Abstract base class providing config storage, URL resolution, retry loop, and status dispatch.
@@ -118,9 +116,7 @@ def __init__(
118116
self._parameters["use_iterator_for_get_pages"] = self._use_iterator_for_get_pages
119117

120118
if self._logger:
121-
self._logger.info(
122-
f"Meraki dashboard API session initialized with these parameters: {self._parameters}"
123-
)
119+
self._logger.info(f"Meraki dashboard API session initialized with these parameters: {self._parameters}")
124120

125121
# ------------------------------------------------------------------
126122
# Abstract methods (subclass contract)
@@ -145,9 +141,7 @@ def _transport_kwargs(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
145141
# Template method: request
146142
# ------------------------------------------------------------------
147143

148-
def request(
149-
self, metadata: Dict[str, Any], method: str, url: str, **kwargs: Any
150-
) -> Optional["httpx.Response"]:
144+
def request(self, metadata: Dict[str, Any], method: str, url: str, **kwargs: Any) -> Optional["httpx.Response"]:
151145
"""Execute an API request with retry loop and status dispatch.
152146
153147
Args:
@@ -185,7 +179,7 @@ def request(
185179
if self._logger:
186180
self._logger.info(f"{method} {abs_url}")
187181
response = self._send_request(method, abs_url, **kwargs)
188-
except Exception as e:
182+
except httpx.HTTPError as e:
189183
if self._logger:
190184
self._logger.warning(f"{tag}, {operation} - {e}, retrying in 1 second")
191185
self._sleep(1)
@@ -261,9 +255,7 @@ def _handle_success(
261255
return response
262256
except (json.decoder.JSONDecodeError, ValueError):
263257
if self._logger:
264-
self._logger.warning(
265-
f"{tag}, {operation} - JSON decode error, retrying in 1 second"
266-
)
258+
self._logger.warning(f"{tag}, {operation} - JSON decode error, retrying in 1 second")
267259
return None
268260

269261
def _handle_redirect(self, response: "httpx.Response") -> str:
@@ -298,24 +290,18 @@ def _handle_rate_limit(
298290
)
299291

300292
if self._logger:
301-
self._logger.warning(
302-
f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds"
303-
)
293+
self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds")
304294
return wait
305295

306-
def _handle_server_error(
307-
self, response: "httpx.Response", metadata: Dict[str, Any]
308-
) -> None:
296+
def _handle_server_error(self, response: "httpx.Response", metadata: Dict[str, Any]) -> None:
309297
"""Handle 5xx server errors. Logs warning before retry."""
310298
tag = metadata["tags"][0]
311299
operation = metadata["operation"]
312300
reason = response.reason_phrase if hasattr(response, "reason_phrase") else ""
313301
status = response.status_code
314302

315303
if self._logger:
316-
self._logger.warning(
317-
f"{tag}, {operation} - {status} {reason}, retrying in 1 second"
318-
)
304+
self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in 1 second")
319305

320306
def _handle_client_error(
321307
self,
@@ -381,9 +367,7 @@ def _retry_with_wait(
381367
status = response.status_code
382368

383369
if self._logger:
384-
self._logger.warning(
385-
f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds"
386-
)
370+
self._logger.warning(f"{tag}, {operation} - {status} {reason}, retrying in {wait} seconds")
387371
self._sleep(wait)
388372
retries -= 1
389373
if retries == 0:
@@ -416,6 +400,5 @@ def _build_headers(self) -> Dict[str, str]:
416400
return {
417401
"Authorization": "Bearer " + self._api_key,
418402
"Content-Type": "application/json",
419-
"User-Agent": f"python-meraki/{self._version} "
420-
+ validate_user_agent(self._be_geo_id, self._caller),
403+
"User-Agent": f"python-meraki/{self._version} " + validate_user_agent(self._be_geo_id, self._caller),
421404
}

tests/unit/test_aio_rest_session.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ class TestAsyncConnectionErrors:
328328
@pytest.mark.asyncio
329329
async def test_retry_on_exception(self, async_session):
330330
resp_200 = _mock_aio_response(status_code=200)
331-
async_session._client.request = AsyncMock(side_effect=[Exception("Connection refused"), resp_200])
331+
async_session._client.request = AsyncMock(side_effect=[httpx.ConnectError("Connection refused"), resp_200])
332332

333333
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
334334
result = await async_session.request(_metadata(), "GET", "/organizations")
@@ -337,10 +337,10 @@ async def test_retry_on_exception(self, async_session):
337337
@pytest.mark.asyncio
338338
async def test_exception_raises_after_max_retries(self, async_session):
339339
async_session._maximum_retries = 2
340-
async_session._client.request = AsyncMock(side_effect=Exception("Connection refused"))
340+
async_session._client.request = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
341341

342342
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
343-
with pytest.raises((AsyncAPIError, Exception)):
343+
with pytest.raises(APIError):
344344
await async_session.request(_metadata(), "GET", "/organizations")
345345

346346

@@ -602,7 +602,7 @@ async def test_logs_request_url(self, async_session_with_logger):
602602
@pytest.mark.asyncio
603603
async def test_logs_warning_on_connection_error(self, async_session_with_logger):
604604
resp_200 = _mock_aio_response(status_code=200)
605-
async_session_with_logger._client.request = AsyncMock(side_effect=[Exception("timeout"), resp_200])
605+
async_session_with_logger._client.request = AsyncMock(side_effect=[httpx.ConnectError("timeout"), resp_200])
606606

607607
with patch(SLEEP_PATCH, side_effect=_noop_sleep):
608608
await async_session_with_logger.request(_metadata(), "GET", "/organizations")

0 commit comments

Comments
 (0)