fix: reject malformed price rows instead of manufacturing zero prices (#15) - #18
Conversation
Price::fromArray defaulted an absent code to '' and an absent or
non-numeric price to 0.0, and historical()/demoPrices() passed rows
straight to it with no validation. Verified on origin/main: a successful
historical envelope with prices=[{}] returned
{"code":"","price":0,"currency":"USD"}, a price of 'not-a-number' returned
0, and a missing or non-array prices field became an empty successful
list. A fabricated zero is indistinguishable from a real quote, so the
caller cannot detect it.
Move validation into Price::fromArray - the single price-row boundary -
and route latest, history and demo through Client::priceOrFail so every
endpoint agrees. A missing or non-array prices field is now a malformed
envelope rather than "no data"; an actual empty list stays empty.
Legitimate zero and negative prices are preserved; truthiness is never
used.
Refs #15
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 |
…dation #17 (origin guard) and this branch both changed Client::request and the helper that sat between latest() and the retry loop. src/Client.php merged cleanly; only the CHANGELOG Unreleased block conflicted, and both entries are kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
Updated for main at
|
src/Client.php auto-merged as predicted: priceOrFail/priceListOrFail from #18, normalizeApiPath/assertSameOrigin from #17 and isRetryable/isDurableQuotaExhausted/retryDelay from this branch all coexist, with every call site in request() intact. Only the CHANGELOG Unreleased bullets conflicted; all three entries are kept, #18's ahead of this branch's two since it landed first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
|
PHP expert review of this branch as proposed ( The core change is right: moving the validation into 1. It leaves the timestamp half of the fabrication open — filed as #20. 2. This is a breaking change and the version says otherwise. The exception design itself is correct — 3. Two smaller things.
On the Full review notes in |
`Price::fromArray()` built the observation timestamp with
`DateTimeImmutable::createFromFormat()` and never called `getLastErrors()`.
PHP reports a repaired parse only through that method, so an impossible value
came back as a perfectly usable object:
"2026-13-45T99:99:99Z" -> 2027-02-18T04:40:39+00:00
"2026-02-30T00:00:00Z" -> 2026-03-02T00:00:00+00:00
The tolerant `new DateTimeImmutable($timestamp)` fallback was looser still and
raised nothing at all for relative expressions:
"now" -> <the moment the SDK ran>
"next friday" -> 2026-09-18T00:00:00+00:00
"0000-00-00" -> -0001-11-30T00:00:00+00:00
This is the same fabrication class as the zero price fixed in #18, and #18 did
not cover it: its guard only caught an outright parse failure. A fabricated
timestamp on a real price is the harder failure to catch, because the number
is right and only its position in time is invented - nobody eyeballs that the
way they eyeball a $0.00 Brent quote.
Timestamps are now matched against an explicit list of absolute formats, and a
parse counts only when `getLastErrors()` reports zero warnings and zero
errors. Anything else raises `ApiException` naming the offending value.
Two deliberate calls beyond the reported inputs:
- Naive timestamps ("2026-07-19 12:00:00") are read as UTC, not as the host's
default timezone, so the same payload does not describe a different instant
on a server in Chicago than on one in London.
- Leap seconds ("23:59:60") are rejected. PHP has no representation for one
and rolls it into the next minute; accepting that would be a silent
one-second shift, the same fabrication in miniature.
Genuine spellings keep working: 'Z' and lowercase 'z', +00:00, +0000 and
offset forms, fractional seconds, the space separator, and date-only values.
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The section documented only the #18 break. A reader deciding whether it is safe to upgrade reads "### Breaking changes" and nothing else, so a break recorded only under "Fixed" or "Security" is one they meet in production. Added to the table: `currency` is now required, a non-string `currency`, and a non-string `unit`/`name`/`source`/`type`/`formatted` (#21). Split the single "unparseable timestamp" row into the two distinct cases #20 introduced - a value PHP repaired into a plausible date, and a relative expression such as `now` - because #18 already rejected the genuinely unparseable and conflating them understates what changed. Added as their own entries: redirects are no longer followed (#22), and the new `lib-curl >= 7.58.0` platform requirement, which is an install-time break on an old host and appears nowhere else a reader would look. Called out the two rejections a reader would not predict: leap seconds, and naive timestamps read as UTC rather than host-local. Extended the "what still works" paragraph to cover absent and null label fields, so a reader can tell the optional fields stayed optional. VersioningTest now fences all of it: eight required breaks, two surprising rejections, and the still-works guarantees. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
…21) (#27) `Price::fromArray()` put unchecked `(string)` casts on `currency`, `unit`, `name`, `source`, `type` and `formatted`. Confirmed on origin/main: currency: ["EUR"] -> "Array" (+ PHP "Array to string conversion") unit: true -> "1" name: 978 -> "978" A mislabelled unit - barrel where the payload said tonne - is the same harm class as a wrong number, and harder to spot, because the number beside it is right. Present-but-non-string values now raise `ApiException` naming the field and its actual type. Absent and explicitly null still mean "not provided". Also breaking: `currency` is required alongside `code` and `price`. It defaulted to 'USD', which labelled a euro-denominated carbon price as dollars. A $0.00 Brent quote is obviously broken and a human catches it; `78.40 USD` on an EUA contract is plausible, roughly 8% wrong, and flows into a model undetected. The catalogue is not USD-only - the repo's own fixtures carry EU_CARBON_EUR. #18 already broke BC on this method, so the strictness costs nothing extra. Surrounding whitespace on `currency` is normalized, the one value the DTO adjusts, so `$price->currency === 'EUR'` behaves. Nothing about the label changes. Existing fixtures that built price rows without a currency were updated; no assertion was weakened. Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: release as 3.0.0 and write down the BC break #18 shipped `Client::VERSION` still read '2.1.2' and the changelog entry carried no version heading at all, while `Price::fromArray()` - public, documented, and callable directly - went from total to partial in #18. An integrator reading either signal would conclude nothing in their code could break. - `Client::VERSION` is 3.0.0. The change shipped to main labelled as a patch; this corrects the label rather than re-releasing the code. - CHANGELOG gets a version heading and a `### Breaking changes` section: a table of exactly what throws now that did not before, what is still preserved (legitimate zero and negative prices, an empty `prices` list, a row with no timestamp), and the code a caller writes to adapt. - README gets an "Upgrading to 3.0" section making the same point where a caller will actually see it. - `VersioningTest` fences all of it: the version is semver, its major is past the last BC-compatible line, the topmost changelog entry names a version and matches `Client::VERSION`, that entry documents the break and what it throws, and the User-Agent carries the same version it claims. PHPStan is deliberately NOT wired in here. Level 9 reports 17 errors on src/, and six of them are the `(string)` casts being removed in the #21 PR while an eleventh is in the transport being rewritten in the #22 PR. Adding it now means either a baseline that conflicts with both, or fixing Client.php in a PR about version numbers. Inventory posted to #24 instead, to land after those two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo * docs: list every 3.0.0 break under Breaking changes, not just the first The section documented only the #18 break. A reader deciding whether it is safe to upgrade reads "### Breaking changes" and nothing else, so a break recorded only under "Fixed" or "Security" is one they meet in production. Added to the table: `currency` is now required, a non-string `currency`, and a non-string `unit`/`name`/`source`/`type`/`formatted` (#21). Split the single "unparseable timestamp" row into the two distinct cases #20 introduced - a value PHP repaired into a plausible date, and a relative expression such as `now` - because #18 already rejected the genuinely unparseable and conflating them understates what changed. Added as their own entries: redirects are no longer followed (#22), and the new `lib-curl >= 7.58.0` platform requirement, which is an install-time break on an old host and appears nowhere else a reader would look. Called out the two rejections a reader would not predict: leap seconds, and naive timestamps read as UTC rather than host-local. Extended the "what still works" paragraph to cover absent and null label fields, so a reader can tell the optional fields stayed optional. VersioningTest now fences all of it: eight required breaks, two surprising rejections, and the still-works guarantees. 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>
Closes #15.
Confirmed live on
origin/main(6879501)Mock-transport probe, successful
status: "success"envelopes throughout:origin/mainreturneddata.prices = [{}]viapastDay(){"code":"","price":0,"currency":"USD"}price: "not-a-number"{"code":"BRENT_CRUDE_USD","price":0,...}data.prices = "garbage"[]— an empty successful listdatawith nopriceskey[]— an empty successful listdata.prices = [{}]viademoPrices(){"code":"","price":0,"currency":"USD"}A
$0.00that the SDK invented is indistinguishable from a real quote. It carries a plausible code, a plausible currency and a successful status, so nothing downstream — a model, an invoice, an alert threshold — can tell it apart from a market print.The fix
One validated boundary,
Price::fromArray, now required for every row:codemust be present, a string, and non-empty after trimmingpricemust be present andis_numeric— checked witharray_key_exists, never truthiness, so0,0.0,"0.00"and-37.63all passnullClient::priceOrFail()routeslatest(), the four historical period methods anddemoPrices()through it with path context in the message.Client::priceListOrFail()separates the two cases the old code conflated: a missing or non-arraypricesfield is a malformed envelope and raises;prices: []is a legitimately empty result and still returns[].Red / green
Red, proven against pre-fix code (
git checkout origin/main -- src/Client.php src/Price.php, rerun, restore):Green, with the fix:
Baseline on clean
origin/mainbefore any change:OK (26 tests, 323 assertions). PHP 8.5.8, Composer 2.10.2, PHPUnit 13.3.3.Coverage: ten malformed-row shapes (empty row, missing/null/non-numeric/empty-string/array price, missing/empty/non-string code, malformed timestamp) run against
latest(), the historical methods anddemoPrices()so all three agree; plus non-arrayprices, missingprices, legitimately emptyprices, and five legitimate numeric prices including zero and negative.One deliberate non-change, stated plainly
currencystill defaults to'USD'when the field is absent. I could not verify the live response schema during this session — the demo endpoint was IP rate-limited and no API key was available — and making an absentcurrencyfatal would break every historical call if the production schema ever omits it. The dangerous fabrication is the price, and that is now fixed. Unverified: whether production ever omitscurrency. Worth a follow-up check against a live response before tightening it.Compatibility
Price::fromArray()is public and now throwsApiExceptionfor rows it previously turned into zeros. That is the defect, not a regression, but it is a behaviour change for anyone calling it directly.ApiExceptionis the documented catch-all, so existing error handling still catches it. The existing'Unexpected latest price shape from /v1/prices/latest.'message is preserved as a prefix.Not merged, no auto-merge, no release tagged.
🤖 Generated with Claude Code
https://claude.ai/code/session_015ao5paex73xXvuM424Libo