fix(transport): accept a successful empty 204 instead of raising (#103) - #137
Merged
Merged
Conversation
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
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
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
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Both clients parsed every 2xx with a bare
response.json()— three sites:client.py:300,client.py:449(request_with_headers) andasync_client.py:262.The Rails webhooks controller answers
destroywithhead :no_content, a 204 with an empty body. So:The deletion succeeded and the caller was told it failed. That is the worst shape of error for a mutation, because the obvious recovery is to retry a delete that already worked.
The fix
One shared
decode_json_bodyhelper in a newoilpriceapi/_body.py, backing all three decode sites. A single helper rather than three copies of the branch is the point — three copies drift, and this repo's sync/async parity is worth keeping.It decodes first and inspects the body only when that fails:
Checking
.contentup front was the first version. It worked, but it touched the body on every successful request and made the helper sensitive to how a response is represented rather than what it contains — the existingtest_async_client.pymocks surfaced that as 7RuntimeWarning: coroutine ... was never awaited. Decode-first has neither problem and the same semantics.Deliberately narrow
{}{}The malformed-non-empty row is the one that matters. A real parse failure must not be laundered into an empty success — that would hide a broken response, which is the mirror image of the bug being fixed. Two tests pin it, one per client.
deletekeeps its documented-> Nonecontract.Red
Tests written first, against unmodified
main:Re-proved red-capable after the fix by restoring the pre-fix sources and deleting the new module (
git checkout origin/main -- oilpriceapi/client.py oilpriceapi/async_client.py; rm oilpriceapi/_body.py) — that is the run above. The 5 that passed before the fix are the negative paths: malformed 200 and 401/403 already behaved correctly and must keep doing so.Green
Sync and async parity
Every positive case is asserted on both clients:
test_sync_delete_accepts_204_no_content/test_async_delete_accepts_204_no_content,test_sync_request_returns_empty_dict_for_204/test_async_request_returns_empty_dict_for_204, and the malformed-200 and 401 negatives likewise.test_all_three_decode_sites_share_one_helperpins it structurally: both modules must referencedecode_json_body, and neither may contain a barereturn response.json()orreturn response.json(), response.headers. A future edit that reintroduces a direct decode in one client fails the test.Verified against the backend contract named in the issue:
v1/webhooks_controller.rbdestroyreturnshead :no_content. Tested against a disposable respx fixture — no production deletes.A note on test mocking
The first push used
respx, which is not in this repo's[dev]extra, so CI failed withModuleNotFoundErroron all five Python versions. A follow-up commit switches to patchinghttpx.Client.request/httpx.AsyncClient.request, which is this repo's existing convention (tests/unit/test_diesel_envelope.py). Same assertions, same red counts — no test dependency added for a handful of assertions.Full suite
origin/main)+16 is exactly this PR's new tests. The 3 failures are
tests/integration/test_demo_contract.pymaking live calls and getting HTTP 429 — environmental, identical before and after.git diff --stat:2 files changed, 5 insertions(+), 3 deletions(-), plus the new_body.pyand the new test file. No CRLF normalisation ofresources/alerts.pyorresources/diesel.py.Version deliberately left at 1.14.0; release decision is separate.
Closes #103.
🤖 Generated with Claude Code
https://claude.ai/code/session_015ao5paex73xXvuM424Libo