This library maintains two HTTP backends (requests for sync, aiohttp for async) with ~80% duplicated logic across rest_session.py (670 lines) and aio/rest_session.py (547 lines). This duplication is the root cause of multiple concerns documented in .planning/codebase/CONCERNS.md:
Tech debt it directly eliminates:
- Sync/async code duplication (bugs must be fixed twice, inconsistencies accumulate)
- High cyclomatic complexity in
AsyncRestSession._request(42), which exists partly because async required a full reimplementation - Two pinned dependencies at risk of breaking changes (
requests<3,aiohttp<4)
Bugs it fixes by replacement:
- Bare
except Exceptionin async handler (replaces with typedhttpx.HTTPError) - Inconsistent error handling between sync (catches
requests.exceptions.RequestException) and async (catches everything) - Blocking
time.sleep()call in async 4xx handler (aio/rest_session.py:268), which blocks the event loop during network-delete retry waits
Quality gaps it creates the opportunity to close:
- No type annotations in core modules (rewrite is the natural time to add them)
- Missing error path test coverage (new code gets new tests)
- Test mocking uses
responseslibrary (requests-only); migration torespxmodernizes the test infra
- Provides
httpx.Client(sync) andhttpx.AsyncClient(async) with an identical API surface - After
await client.request(), response body is already buffered;.json()is synchronous even on the async client (simplifies pagination logic) - Same
verify=,timeout=semantics as requests (minimal learning curve for contributors) proxy=as a simple string (matches current config model)response.linksproperty parses Link headers identically to requests (same{'next': {'url': '...'}}dict format)- Actively maintained, type-annotated from the start, HTTP/2 capable
- Industry momentum: FastAPI, Starlette, and most modern Python HTTP tooling default to or recommend httpx
These concerns remain and require separate work:
- Adaptive retry strategy (app logic, not library choice)
- Pagination memory buffering (iterator pattern already exists)
- API key exposure risk (logging concern, unrelated to transport)
- OASv3 generator migration
- Request cancellation/OpenTelemetry integration (httpx has better primitives, but wiring them up is separate scope)
Before touching HTTP code, capture a passing integration test run against the Meraki sandbox. This becomes the regression gate for all subsequent phases.
- Run existing integration tests, record pass/fail state
- Document which endpoints are exercised
- This baseline validates that Phases 2-3 produce identical external behavior
Create meraki/http_utils.py with one library-agnostic function:
Replaces the monkey-patched requests.models.RequestEncodingMixin._encode_params (rest_session.py:41-107). Reimplements the custom array-of-objects encoding as a pure function using only urllib.parse.urlencode.
Strategy: pre-encode params into a query string and append to the URL before passing to httpx (httpx has no monkey-patch hook for param encoding).
Current behavior:
- Input:
{"param[]": [{"key_1": "value_1"}]} - Output:
param%5B%5Dkey_1=value_1
The existing impl uses requests.utils.to_key_val_list (just .items() on dicts) and requests.compat.basestring (just str in Python 3). Both are trivially replaceable.
Note: response.links does NOT need a replacement utility. httpx provides .links with the same dict format as requests.
Create meraki/_session_base.py extracting shared logic from both session files:
- All configuration storage (api_key, base_url, timeouts, retries, proxy, cert)
- Header construction
- URL resolution and validation
- Retry decision logic (
_should_retry_4xx,_get_retry_wait) - Param encoding dispatch (
_apply_paramscallsencode_meraki_params)
The two concrete session classes become thin I/O layers over this base.
Design decision for sync/async split: The base class holds all decision logic (should we retry? how long to wait? what error to raise?) but does NOT hold the retry loop itself, because the loop calls time.sleep() (sync) vs await asyncio.sleep() (async). Each concrete class implements _execute_with_retry using the base's decision methods. This keeps the base simple and avoids abstract-method overhead.
Rewrite meraki/rest_session.py to use httpx.Client:
| requests | httpx |
|---|---|
requests.session() |
httpx.Client(headers=..., verify=..., proxy=..., timeout=..., follow_redirects=False) |
session.request(method, url, allow_redirects=False, **kwargs) |
self._client.request(method, url, **kwargs) |
requests.exceptions.RequestException |
httpx.HTTPError |
response.reason |
response.reason_phrase |
response.links |
response.links (same API) |
verify=path |
verify=path (same) |
proxies={"https": url} |
proxy=url |
timeout=60 |
timeout=60 (same) |
Key: params are pre-encoded into the URL via _apply_params(), so httpx never sees params=.
Important: Remove the monkey-patch (requests.models.RequestEncodingMixin._encode_params = encode_params at line 107) in this same phase. If requests remains importable (e.g., generator scripts), the monkey-patch must not fire at SDK import time.
Rewrite meraki/aio/rest_session.py to use httpx.AsyncClient:
| aiohttp | httpx |
|---|---|
aiohttp.ClientSession(headers=..., timeout=aiohttp.ClientTimeout(...)) |
httpx.AsyncClient(headers=..., verify=..., proxy=..., timeout=..., follow_redirects=False) |
response.status |
response.status_code |
await response.json(content_type=None) |
response.json() (sync after await on request) |
ssl=ssl_context |
verify=path (httpx handles SSLContext internally) |
proxy=url (singular) |
proxy=url (same) |
response.release() |
(delete, body already buffered) |
async with await self.request(...) as response: |
response = await self.request(...) |
response.links |
response.links (same API) |
Structural changes beyond the table:
- All 6
async with await self.request(...) as response:patterns (get, post, put, delete, _get_pages_legacy x2) become simple assignment. This is a pervasive rewrite, not a find-replace. response.release()calls in the async iterator are deleted (httpx buffers fully on await).content_type=Noneinresponse.json()calls (~10 occurrences) is dropped silently; httpx doesn't validate MIME type by default.
The asyncio.Semaphore for concurrency control and asyncio.create_task for page pre-fetching remain unchanged.
Modify meraki/exceptions.py:
Current state:
APIError.__init__(metadata, response)usesresponse.status_code,response.reasonAsyncAPIError.__init__(metadata, response, message)usesresponse.status,response.reason, separatemessageparam
These have different signatures and different attribute sources. Unifying requires:
-
Change
APIError:response.reason->response.reason_phraseresponse.content->response.content(same in httpx)
-
Change
AsyncAPIError:response.status->response.status_coderesponse.reason->response.reason_phrase
-
Deprecation path for
AsyncAPIError:- Keep the class but make it a subclass of
APIErrorwith a compatibility__init__that accepts the old 3-arg signature - Add a deprecation warning when instantiated directly
- Document in CHANGELOG that users should catch
APIErrorfor both sync and async in future versions
- Keep the class but make it a subclass of
Status: Deprecated as of v4.0. Use APIError for both sync and async exceptions.
In previous versions, the SDK used two separate exception classes:
APIErrorfor synchronous errorsAsyncAPIErrorfor asynchronous errors
Starting in v4.0, both sync and async sessions raise exceptions that inherit from APIError. The AsyncAPIError class remains available for backwards compatibility but is deprecated.
Before (v3.x):
from meraki.aio import AsyncDashboardAPI
from meraki.exceptions import AsyncAPIError
async with AsyncDashboardAPI(api_key=API_KEY) as aiomeraki:
try:
response = await aiomeraki.organizations.getOrganizations()
except AsyncAPIError as e:
print(f"Error: {e.status} {e.reason}")After (v4.0+):
from meraki.aio import AsyncDashboardAPI
from meraki.exceptions import APIError # Changed
async with AsyncDashboardAPI(api_key=API_KEY) as aiomeraki:
try:
response = await aiomeraki.organizations.getOrganizations()
except APIError as e: # Changed
print(f"Error: {e.status} {e.reason}")Existing code using except AsyncAPIError: will continue to work because AsyncAPIError is now a subclass of APIError. However, you will see a DeprecationWarning when the exception is raised.
To suppress the warning during migration:
import warnings
warnings.filterwarnings('ignore', category=DeprecationWarning, module='meraki')Update exception handlers to catch APIError instead of AsyncAPIError. This future-proofs your code and eliminates deprecation warnings.
Modify pyproject.toml:
dependencies = [
"httpx>=0.28,<1",
]
[dependency-groups]
dev = [
"respx>=0.22,<1", # replaces 'responses'
# ... rest unchanged
]Remove: requests, aiohttp, responses
Note on upper pin: <1 guards against httpx 1.0 API breaks (they've discussed backwards-incompatible changes for 1.0). Re-evaluate when 1.0 ships.
This phase MUST be atomic with Phases 3-4. If requests remains installed while the new code is live, the old monkey-patch import path could still fire from stale .pyc caches or editable installs.
| Test file | Change |
|---|---|
tests/unit/test_rest_session.py |
Mock httpx.Response instead of requests.Response; .reason -> .reason_phrase; .links remains same API |
tests/unit/test_aio_rest_session.py |
Replace aiohttp mocks with httpx mocks; .status -> .status_code; remove __aenter__/__aexit__ patterns; .json() no longer awaitable |
tests/unit/test_mock_integration.py |
Replace responses library with respx |
| Integration tests | Re-run baseline from Phase 0, confirm identical pass/fail |
| Concern | Resolution |
|---|---|
requests_proxy parameter name |
Keep it. Pass through as proxy= internally. |
REQUESTS_PROXY config constant |
Keep it. Just a default value string. |
AsyncAPIError class |
Keep as deprecated subclass of APIError. |
_req_session internal attribute |
Add deprecation property mapping to _client. |
The current AsyncRestSession._request has complexity 40 (4x industry ceiling). During rewrite, decompose into:
| Method | Responsibility |
|---|---|
_execute_with_retry |
Retry loop, attempt counting, backoff timing |
_handle_rate_limit |
429 detection, Retry-After parsing, wait logic |
_handle_error_response |
4xx/5xx classification, exception raising |
_log_request |
Request/response debug logging |
Each method stays under complexity 10. Decision logic lives in the base class; sync/async layers differ only in sleep/request calls.
Type-annotate the new unified session base class and both thin I/O layers. This is the natural place to introduce typing since the code is being rewritten anyway, and the shared base class gives ~80% coverage for free.
- All public methods get full signatures (params and return types)
- Use
httpx.Responsedirectly (no wrapper) - Add
py.typedmarker (PEP 561) in the same commit
Add hypothesis property-based tests for the param encoding utility:
| Function | Properties to verify |
|---|---|
encode_meraki_params |
Roundtrip: parsed output matches input structure; never produces bare = without key; handles empty dicts/lists; output is valid URL query string |
Add hypothesis to dev dependencies in pyproject.toml.
generator/generate_library.py and siblings use requests.get() at build-time to fetch OpenAPI specs. Not shipped to users. Can migrate separately or leave as dev-only dependency.
These items from TODO.md are eliminated or simplified by this migration:
- Stream 1, Step 3 (Eliminate sync/async duplication) - obsolete
- Stream 1, Step 1 (Async
_requestcomplexity 42) - rewrite > refactor - Stream 1, Step 2 (
_get_pages_legacycomplexity) - rewrite > refactor - Stream 3 (Cover 36 missing lines) - those lines get replaced
- Add integration test for async client - one client = one test surface
- httpx sync responses are fully buffered (like requests). No behavior change.
- httpx async responses: body already read after
await client.request(). Simplifies async code. - Connection pooling:
Client/AsyncClientmaintain pools likerequests.Session. - Timeout: httpx default is 5s, but SDK explicitly sets 60s. No issue.
- Certificate verification:
verify="/path/to/cert.pem"works identically. - Proxy:
proxy="http://host:port"as string. Direct pass-through. - Monkey-patch removal: must happen atomically with requests removal to avoid import-time side effects on other packages.
| Step | Risk | Validation |
|---|---|---|
| Phase 0 (integration baseline) | None | Record current pass/fail state |
| Phase 1 (utilities) | None | Unit tests for encode function |
| Phase 2 (base class) | None | Unit tests for shared logic |
| Phase 3 (sync rewrite) | High | Full sync test suite passes |
| Phase 4 (async rewrite) | High | Full async test suite passes |
| Phase 5 (exceptions) | Medium | Error formatting matches expected output |
| Phase 6 (dependencies) | Medium | pip install -e . succeeds; atomic with 3-4 |
| Phase 7 (tests) | Medium | All tests green |
| Phase 8 (compat) | Low | Existing user code still works |
| Phase 9 (decompose) | Low | Complexity scores under 10 per method |
| Phase 10 (types) | Low | mypy passes |
| Phase 11 (property tests) | Low | hypothesis finds no violations |
| Phase 12 (generator) | None | Optional, dev-only |
| Integration gate | Critical | Live API tests pass against Meraki sandbox |