Skip to content

fix: reject malformed price rows instead of manufacturing zero prices (#15) - #18

Merged
karlwaldman merged 2 commits into
mainfrom
fix/15-reject-malformed-price-rows
Sep 13, 2026
Merged

karlwaldman merged 2 commits into
mainfrom
fix/15-reject-malformed-price-rows

Conversation

@karlwaldman

Copy link
Copy Markdown
Member

Closes #15.

Confirmed live on origin/main (6879501)

Mock-transport probe, successful status: "success" envelopes throughout:

response origin/main returned
data.prices = [{}] via pastDay() {"code":"","price":0,"currency":"USD"}
price: "not-a-number" {"code":"BRENT_CRUDE_USD","price":0,...}
data.prices = "garbage" [] — an empty successful list
data with no prices key [] — an empty successful list
data.prices = [{}] via demoPrices() {"code":"","price":0,"currency":"USD"}

A $0.00 that 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:

  • code must be present, a string, and non-empty after trimming
  • price must be present and is_numeric — checked with array_key_exists, never truthiness, so 0, 0.0, "0.00" and -37.63 all pass
  • a timestamp that is present but unparseable raises instead of silently becoming null

Client::priceOrFail() routes latest(), the four historical period methods and demoPrices() through it with path context in the message. Client::priceListOrFail() separates the two cases the old code conflated: a missing or non-array prices field 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):

Tests: 67, Assertions: 369, Failures: 26.

1) MalformedPriceRowTest::testHistoricalRejectsMalformedRow@empty row with data ([])
Failed asserting that exception of type "OilPriceAPI\Exception\ApiException" is thrown.
4) MalformedPriceRowTest::testHistoricalRejectsMalformedRow@non-numeric price with data (['BRENT_CRUDE_USD', 'not-a-number'])
Failed asserting that exception of type "OilPriceAPI\Exception\ApiException" is thrown.

Green, with the fix:

OK (67 tests, 369 assertions)

Baseline on clean origin/main before 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 and demoPrices() so all three agree; plus non-array prices, missing prices, legitimately empty prices, and five legitimate numeric prices including zero and negative.

One deliberate non-change, stated plainly

currency still 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 absent currency fatal 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 omits currency. Worth a follow-up check against a live response before tightening it.

Compatibility

Price::fromArray() is public and now throws ApiException for rows it previously turned into zeros. That is the defect, not a regression, but it is a behaviour change for anyone calling it directly. ApiException is 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

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
@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 4b2eaf30-d8cc-4144-a198-d5b536d72bed


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…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
@karlwaldman

Copy link
Copy Markdown
Member Author

Updated for main at 3feb505fa (after #17 merged)

origin/main was merged into this branch. src/Client.php merged cleanly — #17's origin guard sits in request() and its own helper block, this branch's validation sits in the price-decoding helpers, and the two do not overlap. The only conflict was the ## Unreleased block in CHANGELOG.md; both entries are kept, ### Security (origin guard) above ### Fixed (this change).

I first resolved this as a rebase, then reproduced it as a merge so the branch updates without a force-push. The resulting trees are byte-identical (5b84303), so what is pushed here is exactly the resolution I verified.

Nothing had to be dropped. Both changes survive in full.

New baseline, clean origin/main at 3feb505f: OK (50 tests, 380 assertions) — up from OK (26 tests, 323 assertions), since #17 added RawPathOriginTest.

Re-proven against the new main (git checkout origin/main -- src/Client.php src/Price.php, rerun, restore):

Tests: 91, Assertions: 426, Failures: 26.

1) MalformedPriceRowTest::testHistoricalRejectsMalformedRow@empty row with data ([])
Failed asserting that exception of type "OilPriceAPI\Exception\ApiException" is thrown.

2) MalformedPriceRowTest::testHistoricalRejectsMalformedRow@missing price with data (['BRENT_CRUDE_USD'])
Failed asserting that exception of type "OilPriceAPI\Exception\ApiException" is thrown.

All 26 failures are MalformedPriceRowTest. In that same pre-fix state RawPathOriginTest still reports OK (24 tests, 57 assertions), which confirms reverting this branch's files leaves #17's guard untouched — the red is this defect and nothing else.

Green, with the fix, on top of the new main: OK (91 tests, 426 assertions). Packaged Composer smoke passes.

The currency question stays open and unchanged, as asked.

@karlwaldman
karlwaldman merged commit 3537cc4 into main Sep 13, 2026
8 checks passed
@karlwaldman
karlwaldman deleted the fix/15-reject-malformed-price-rows branch September 13, 2026 16:39
karlwaldman added a commit that referenced this pull request Sep 13, 2026
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
@karlwaldman

Copy link
Copy Markdown
Member Author

PHP expert review of this branch as proposed (cc74b0e). Suite runs green here: OK (91 tests, 426 assertions) on PHP 8.5.8 / PHPUnit 13.3.3. Not touching the branch — rebase in progress.

The core change is right: moving the validation into Price::fromArray() and routing latest(), the historical methods and demoPrices() through one priceOrFail() boundary is the correct shape, and treating a missing prices key as a malformed envelope rather than an empty result is the detail most implementations get wrong. Three things to settle before it merges.

1. It leaves the timestamp half of the fabrication open — filed as #20. if (!$parsed instanceof DateTimeImmutable) throw only catches parsing that fails outright. createFromFormat(ATOM, '2026-13-45T99:99:99Z') returns a valid object (2027-02-18T04:40:39+00:00) and reports the problem only via DateTimeImmutable::getLastErrors(), which is never called; and the new DateTimeImmutable($ts) fallback accepts "now", "next friday", "0000-00-00". Executed on this branch. The PR's test covers one input, 'not-a-date', which is the one case that does throw.

2. This is a breaking change and the version says otherwise. Price::fromArray() is public static on a class with no @internal, and it is documented in the README's public surface. It goes from total (always returns a Price) to partial (throws). Anyone calling it directly on a stored payload gets an uncaught RuntimeException after what currently looks like a patch. Client::VERSION is still '2.1.2' and the changelog entry sits under ### Fixed with no version heading. Under semver this is 3.0.0. If a major is unwanted right now, the alternative is to keep fromArray() total and put the strictness behind a new Price::fromApiRow() that Client alone calls. Either is defensible; shipping it as a patch is not.

The exception design itself is correct — ApiException extends RuntimeException and the three subclasses extend it, so existing catch (ApiException $e) code catches every new throw, and no message or log line carries key material (the credential lives only in the Authorization header; ApiException::$responseBody is server-supplied).

3. Two smaller things.

  • priceOrFail() re-wraps ApiException into a fresh ApiException without a previous, so the original throw site vanishes from the trace. ApiException::__construct has no $previous slot at all — worth adding while the constructor is already changing.
  • One bad row fails the entire call. Right for price; for updatedAt on pastYear() it discards thousands of good rows because row 4,000 has a bad date. Worth being a deliberate decision rather than inherited from the price rule.

On the currency default, deliberately left open in the issue: it is the same fabrication class, and this repo's own fixture proves it — tests/ClientTest.php:404 uses EU_CARBON_EUR, so the catalogue is not USD-only and ?? 'USD' can label a EUR carbon price as USD. That is worse than a $0.00, which is self-evidently broken; 78.40 USD on an EUA contract is plausible and silently wrong. My recommendation is to make currency required alongside code and price — this PR already breaks BC on the method, so the extra strictness costs nothing — but that is Karl's call, and the remaining (string) coercion on currency/unit/name/source/type/formatted is filed separately as #21.

Full review notes in review-php.md; repro scripts for every claim above are in the review.

karlwaldman added a commit that referenced this pull request Sep 13, 2026
`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>
karlwaldman added a commit that referenced this pull request Sep 13, 2026
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
karlwaldman added a commit that referenced this pull request Sep 13, 2026
…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>
karlwaldman added a commit that referenced this pull request Sep 13, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P1][Review] Reject malformed price rows instead of manufacturing zero-dollar data

1 participant