Skip to content

Commit ffe5123

Browse files
karlwaldmanclaude
andauthored
fix(transport): accept a successful empty 204 instead of raising (#103) (#137)
* fix(transport): accept a successful empty 204 instead of raising (#103) Both clients parsed every 2xx with a bare `response.json()`. The Rails webhooks controller answers `destroy` with `head :no_content` -- a 204 with an empty body -- so `client.webhooks.delete(...)` raised JSONDecodeError. The deletion had SUCCEEDED and the caller was told it failed, which is the worst shape of error for a mutation: the obvious recovery is to retry a delete that already worked. One shared `decode_json_body` helper now backs all three decode sites -- `client.request`, `client.request_with_headers` and `async_client.request` -- so the sync and async paths cannot drift. It decodes first and inspects the body only when that fails, which keeps the body untouched on the hot path. Deliberately narrow: - an empty or whitespace-only body on any 2xx is success, returning {}; - a MALFORMED NON-EMPTY body is still an error, never laundered into an empty success; - 401/403/429 typed errors are untouched, and a 204 is not retried. `delete` keeps its documented `-> None` contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo * style: sort the new _body import to satisfy ruff I001 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo * test(transport): mock the transport the way this repo already does respx is not in the [dev] extra, so every test in this file failed CI with ModuleNotFoundError. Patch httpx.Client.request / httpx.AsyncClient.request instead, matching tests/unit/test_diesel_envelope.py, rather than adding a test dependency. Same assertions, same red: 11 failed, 5 passed against pre-fix sources. The mocked no-content response now raises JSONDecodeError from json() with empty content, which is what httpx actually does -- a closer double than respx gave. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 69c8dc2 commit ffe5123

4 files changed

Lines changed: 251 additions & 3 deletions

File tree

‎oilpriceapi/_body.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Decode a successful response body, tolerating a legitimately empty one (#103).
2+
3+
Both clients used to parse every 2xx with a bare ``response.json()``. The Rails
4+
webhooks controller answers ``destroy`` with ``head :no_content`` -- a 204 with
5+
an empty body -- so ``client.webhooks.delete(...)`` raised ``JSONDecodeError``.
6+
The deletion had SUCCEEDED and the caller was told it failed, which is the worst
7+
shape of error for a mutation: the obvious recovery is to retry a delete that
8+
already worked.
9+
10+
The rule is deliberately narrow. An empty body on a 2xx is success. A malformed
11+
NON-EMPTY body is still an error -- that is a real parse failure and laundering
12+
it into an empty success would hide a broken response.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from typing import Any, Dict
18+
19+
import httpx
20+
21+
__all__ = ["decode_json_body"]
22+
23+
24+
def decode_json_body(response: httpx.Response) -> Dict[str, Any]:
25+
"""Return the decoded 2xx body, or ``{}`` when the server sent none.
26+
27+
Args:
28+
response: A response whose status is already known to be 2xx.
29+
30+
Returns:
31+
The decoded JSON body, or an empty dict for a no-content response.
32+
33+
Raises:
34+
Whatever ``response.json()`` raises for a non-empty body that is not
35+
valid JSON. A malformed response is still a failure.
36+
"""
37+
# Decode first and inspect the body only when that fails. Checking
38+
# `.content` up front would touch the body on every successful request --
39+
# needless work on the hot path, and it makes the helper sensitive to how a
40+
# response is represented rather than to what it contains.
41+
try:
42+
return response.json()
43+
except ValueError:
44+
# 204 No Content and 304 Not Modified are DEFINED to carry no body, and
45+
# some servers answer a mutation with a zero-length 200 or 202. A body
46+
# of only whitespace is as empty as a zero-length one. In every one of
47+
# those cases the request SUCCEEDED and there is simply nothing to
48+
# decode, so an empty dict is the honest answer.
49+
if not (response.content or b"").strip():
50+
return {}
51+
# A malformed NON-EMPTY body is a real parse failure. Laundering it into
52+
# an empty success would hide a broken response from the caller.
53+
raise

‎oilpriceapi/async_client.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
logger = logging.getLogger(__name__)
1818

19+
from ._body import decode_json_body
1920
from ._subscriptions_common import unwrap_data
2021
from ._url import resolve_api_url
2122
from .async_resources import (
@@ -259,7 +260,7 @@ async def request(
259260
duration=_time.time() - start_time,
260261
success=True,
261262
)
262-
return response.json()
263+
return decode_json_body(response)
263264
if response.status_code == 429:
264265
retry_after = response.headers.get("Retry-After")
265266
logger.warning(

‎oilpriceapi/client.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
logger = logging.getLogger(__name__)
1818

19+
from ._body import decode_json_body
1920
from ._subscriptions_common import unwrap_data
2021
from ._url import resolve_api_url
2122
from .exceptions import (
@@ -297,7 +298,7 @@ def request(
297298
duration=time.time() - start_time,
298299
success=True,
299300
)
300-
return response.json()
301+
return decode_json_body(response)
301302
if response.status_code == 429:
302303
retry_after = response.headers.get("Retry-After")
303304
logger.warning(
@@ -446,7 +447,7 @@ def request_with_headers(
446447
)
447448

448449
if 200 <= response.status_code < 300:
449-
return response.json(), response.headers
450+
return decode_json_body(response), response.headers
450451
if response.status_code == 429:
451452
retry_after = response.headers.get("Retry-After")
452453

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""A successful 204 must not be reported to the caller as a failure (#103).
2+
3+
Both clients parse EVERY 2xx with `response.json()`. The Rails webhooks
4+
controller answers `destroy` with `head :no_content` -- a 204 with an empty
5+
body -- so `client.webhooks.delete(...)` raises JSONDecodeError. The deletion
6+
SUCCEEDED and the caller was told it failed, which is the worst shape of error
7+
for a mutation: the obvious recovery is to retry a delete that already worked.
8+
9+
Narrow by design:
10+
- an empty body on a 2xx is success, and `delete` keeps returning None;
11+
- a MALFORMED NON-EMPTY 200 stays an error -- it is a real parse failure and
12+
must not be laundered into an empty success;
13+
- typed errors for 401/403 are untouched, and a 204 is not retried.
14+
15+
Transport mocking follows this repo's existing convention -- patching
16+
`httpx.Client.request` / `httpx.AsyncClient.request`, as
17+
tests/unit/test_diesel_envelope.py does -- so no extra HTTP-mocking dependency
18+
is needed.
19+
"""
20+
21+
import json
22+
from unittest.mock import Mock, patch
23+
24+
import pytest
25+
26+
from oilpriceapi import AsyncOilPriceAPI, OilPriceAPI
27+
from oilpriceapi.exceptions import AuthenticationError, OilPriceAPIError
28+
29+
# Not a credential: a fixture string, every request here is mocked.
30+
FIXTURE_KEY = "-".join(["fixture", "not", "a", "real", "key"])
31+
32+
WEBHOOK = "/v1/webhooks/fixture-id"
33+
34+
35+
def _no_content(status=204, body=b""):
36+
"""A real no-content response: json() raises, content is empty."""
37+
response = Mock()
38+
response.status_code = status
39+
response.headers = {}
40+
response.content = body
41+
response.text = body.decode()
42+
response.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
43+
return response
44+
45+
46+
def _malformed(status=200, body=b"{not json"):
47+
"""A real parse failure: json() raises but the body is NOT empty."""
48+
response = Mock()
49+
response.status_code = status
50+
response.headers = {}
51+
response.content = body
52+
response.text = body.decode()
53+
response.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
54+
return response
55+
56+
57+
def _error(status, payload):
58+
response = Mock()
59+
response.status_code = status
60+
response.headers = {}
61+
response.content = json.dumps(payload).encode()
62+
response.text = json.dumps(payload)
63+
response.json.return_value = payload
64+
return response
65+
66+
67+
def _sync():
68+
return OilPriceAPI(api_key=FIXTURE_KEY, max_retries=1)
69+
70+
71+
def _async():
72+
return AsyncOilPriceAPI(api_key=FIXTURE_KEY, max_retries=1)
73+
74+
75+
# --- 204 with no body is success -------------------------------------------
76+
77+
@patch("httpx.Client.request")
78+
def test_sync_delete_accepts_204_no_content(mock_request):
79+
mock_request.return_value = _no_content()
80+
assert _sync().webhooks.delete("fixture-id") is None
81+
82+
83+
@pytest.mark.asyncio
84+
@patch("httpx.AsyncClient.request")
85+
async def test_async_delete_accepts_204_no_content(mock_request):
86+
mock_request.return_value = _no_content()
87+
assert await _async().webhooks.delete("fixture-id") is None
88+
89+
90+
@patch("httpx.Client.request")
91+
def test_sync_request_returns_empty_dict_for_204(mock_request):
92+
mock_request.return_value = _no_content()
93+
assert _sync().request("DELETE", WEBHOOK) == {}
94+
95+
96+
@pytest.mark.asyncio
97+
@patch("httpx.AsyncClient.request")
98+
async def test_async_request_returns_empty_dict_for_204(mock_request):
99+
mock_request.return_value = _no_content()
100+
assert await _async().request("DELETE", WEBHOOK) == {}
101+
102+
103+
@patch("httpx.Client.request")
104+
def test_request_with_headers_accepts_204(mock_request):
105+
"""The third decode site, which #103 names alongside the other two."""
106+
response = _no_content()
107+
response.headers = {"X-Request-Id": "abc"}
108+
mock_request.return_value = response
109+
110+
body, headers = _sync().request_with_headers("DELETE", WEBHOOK)
111+
112+
assert body == {}
113+
assert headers["X-Request-Id"] == "abc"
114+
115+
116+
@pytest.mark.parametrize("status", [200, 202, 204])
117+
@patch("httpx.Client.request")
118+
def test_any_2xx_with_an_empty_body_is_success(mock_request, status):
119+
"""A 200 or 202 with a genuinely empty body is the same situation."""
120+
mock_request.return_value = _no_content(status=status)
121+
assert _sync().request("DELETE", WEBHOOK) == {}
122+
123+
124+
@patch("httpx.Client.request")
125+
def test_whitespace_only_body_is_treated_as_empty(mock_request):
126+
mock_request.return_value = _no_content(status=200, body=b"\n \n")
127+
assert _sync().request("DELETE", WEBHOOK) == {}
128+
129+
130+
# --- what must STILL fail ---------------------------------------------------
131+
132+
@patch("httpx.Client.request")
133+
def test_malformed_nonempty_200_is_still_an_error(mock_request):
134+
"""A real parse failure must not be laundered into an empty success."""
135+
mock_request.return_value = _malformed()
136+
with pytest.raises(json.JSONDecodeError):
137+
_sync().request("GET", "/v1/prices/latest")
138+
139+
140+
@pytest.mark.asyncio
141+
@patch("httpx.AsyncClient.request")
142+
async def test_async_malformed_nonempty_200_is_still_an_error(mock_request):
143+
mock_request.return_value = _malformed()
144+
with pytest.raises(json.JSONDecodeError):
145+
await _async().request("GET", "/v1/prices/latest")
146+
147+
148+
@patch("httpx.Client.request")
149+
def test_204_is_not_retried(mock_request):
150+
"""A success must not burn the retry budget."""
151+
mock_request.return_value = _no_content()
152+
_sync().request("DELETE", WEBHOOK)
153+
assert mock_request.call_count == 1
154+
155+
156+
@patch("httpx.Client.request")
157+
def test_401_still_raises_authentication_error(mock_request):
158+
mock_request.return_value = _error(401, {"error": "bad key"})
159+
with pytest.raises(AuthenticationError):
160+
_sync().request("DELETE", WEBHOOK)
161+
162+
163+
@patch("httpx.Client.request")
164+
def test_403_still_raises_a_typed_error(mock_request):
165+
mock_request.return_value = _error(403, {"error": "forbidden"})
166+
with pytest.raises(OilPriceAPIError):
167+
_sync().request("DELETE", WEBHOOK)
168+
169+
170+
@pytest.mark.asyncio
171+
@patch("httpx.AsyncClient.request")
172+
async def test_async_401_still_raises_authentication_error(mock_request):
173+
mock_request.return_value = _error(401, {"error": "bad key"})
174+
with pytest.raises(AuthenticationError):
175+
await _async().request("DELETE", WEBHOOK)
176+
177+
178+
# --- sync/async parity ------------------------------------------------------
179+
180+
def test_all_three_decode_sites_share_one_helper():
181+
"""client.request, client.request_with_headers and async_client.request
182+
must decode identically; three copies of the branch would drift."""
183+
import inspect
184+
185+
from oilpriceapi import async_client, client
186+
187+
sync_src = inspect.getsource(client)
188+
async_src = inspect.getsource(async_client)
189+
for src, name in ((sync_src, "client"), (async_src, "async_client")):
190+
assert "decode_json_body" in src, name
191+
# No bare `return response.json()` left behind.
192+
assert "return response.json()" not in src, name
193+
assert "return response.json(), response.headers" not in sync_src

0 commit comments

Comments
 (0)