Skip to content

fix(futures): stop truncating a commodity code into a different contract - #111

Merged
karlwaldman merged 1 commit into
mainfrom
fix/futures-slug-no-silent-rewrite
Sep 13, 2026
Merged

karlwaldman merged 1 commit into
mainfrom
fix/futures-slug-no-silent-rewrite

Conversation

@karlwaldman

Copy link
Copy Markdown
Member

Same bug class as the mcp-server substitution released today as v3.3.0: a local resolver rewriting a real code onto a different instrument, silently.

Defect

normalize_futures_slug (oilpriceapi/resources/_futures_slug.py) split on the first separator and kept only the leading token. That discards the geography and the currency — exactly the fields that distinguish these instruments.

Executed against all 604 live catalog codes on 2026-09-13: 586 raised ValueError (correct), 18 silently rewrote.

requested returned
LNG_NW_EUROPE_EUR lng-jkm — NW Europe LNG in EUR → Japan/Korea Marker in USD
LNG_SOUTH_EUROPE_EUR lng-jkm
LNG_EU_AVERAGE_EUR lng-jkm
WTI_MIDLAND_USD wti — Permian basis grade → Cushing WTI
WTI_SPOT_CUSHING_USD wti — spot → futures curve
BRENT_SPOT_EUROPE_USD brent

Reachable from every method in resources/futures.py (lines 340–411). The caller receives another contract's curve and has no way to tell.

Fix

The suffix stripping exists for a legitimate reason — month and order markers on contract codes (CL.1, CL1!, BZ-2025-12) — so it is kept. What changed is that the discarded tail must actually look like a month or order marker. An alphabetic tail carrying geography and currency is no longer thrown away, and the input raises ValueError as it already did for the other 586 codes.

A refusal is recoverable; a wrong instrument's curve is not.

Evidence

Red  (before): 6 failed, 34 passed
Green (after): 40 passed

All 604 live codes through the fixed resolver:
  checked 604   raised=604   silently rewritten=0

Contract-code forms still resolve: CL.1 → wti, CL1! → wti, BZ → brent, NG → natural-gas

Pre-existing failures, NOT caused by this change

The wider tests/unit suite has 109 failures on clean origin/main (mostly test_streaming.py). Verified identical with and without this change by reverting the file and re-running the same scope. Untouched here.

Related, not in scope

resources/prices.py:41 stamps the requested code onto the response when the response omits one. That manufactures the appearance of a match and would defeat a consumer-side data.code == requested check — worth its own fix, since that assertion is the durable guard for this whole bug class.

🤖 Generated with Claude Code

https://claude.ai/code/session_015ao5paex73xXvuM424Libo

`normalize_futures_slug` split on the first separator and kept the leading
token, discarding the geography and the currency -- the two fields that
distinguish these instruments. A caller asking for one thing received another
contract's curve, with no error.

Measured against all 604 live catalog codes on 2026-09-13: 586 raised
(correct), 18 silently rewrote.

  LNG_NW_EUROPE_EUR     -> lng-jkm   NW Europe LNG in EUR -> Japan/Korea Marker in USD
  LNG_SOUTH_EUROPE_EUR  -> lng-jkm
  LNG_EU_AVERAGE_EUR    -> lng-jkm
  WTI_MIDLAND_USD       -> wti       Permian basis grade -> Cushing WTI
  WTI_SPOT_CUSHING_USD  -> wti       spot -> futures curve
  BRENT_SPOT_EUROPE_USD -> brent

Reachable from every method in resources/futures.py (lines 340-411). Same class
as the mcp-server substitution shipped as v3.3.0 today.

The suffix stripping exists for a real reason -- month and order markers on
contract codes ("CL.1", "CL1!", "BZ-2025-12") -- so it is kept, but the
discarded tail must now actually look like a month/order marker. Alphabetic
tails carrying geography and currency are no longer thrown away, and an
unrecognised input raises ValueError as it already did for the other 586.

After the fix all 604 live codes raise and none are rewritten; the four
contract-code forms still resolve.

Red before: 6 failed, 34 passed. Green after: 40 passed.
Wider unit suite unchanged at 109 pre-existing failures, verified identical on
clean origin/main.

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: 74b9d285-ffaa-495e-97d4-0950b4df5cb5


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.

@karlwaldman
karlwaldman merged commit 6e1a66c into main Sep 13, 2026
7 checks passed
@karlwaldman
karlwaldman deleted the fix/futures-slug-no-silent-rewrite branch September 13, 2026 15:48
karlwaldman added a commit that referenced this pull request Sep 13, 2026
#122) (#125)

* fix(url,retry,futures): un-break today's three regressions (#119, #118, #122)

All three shipped today and all three are reachable from the SDK's own code.

#119 -- `_url.py` rejected U+0020 SPACE. `range(0x21)` is 0x00-0x20 INCLUSIVE;
the comment above it names CR/LF request splitting and NUL, all below 0x20, so
the range over-reached by exactly one character. `/v1/prices?name=Brent Crude`
raised where it previously worked, and `resources/demo.py` builds exactly that
shape (`path = f"{path}?codes={','.join(codes)}"`). A space cannot introduce an
authority or split a request line -- httpx percent-encodes it -- so nothing the
guard exists to stop is enabled by allowing it. Now `range(0x20)`; 0x00-0x1F,
0x7F and backslash stay refused, pinned by tests.

#118 -- #115 keyed replay safety on the HTTP method, which is right, but no
audit of this SDK's own call sites was done, so its POST-shaped READS lost all
retries. A station lookup is idempotent by construction: same lat/lng/radius,
same answer, nothing created. Removing its retries was a pure availability
regression -- a call that used to ride out one bad gateway response now fails
on the first blip.

The rule used to classify them: a POST is read-shaped when the endpoint creates
or mutates no server-side resource AND the same request sent twice returns the
same answer. Six call sites qualify and now pass `idempotent=True` -- diesel
`get_stations`, `webhooks.test`, `alerts.test`, each in both clients.
`data_sources.test` deliberately does NOT: it triggers a fetch against the
customer's configured source and can write to its ingest log. `create()` and
`rotate_credentials()` are unchanged and still sent exactly once.

`test_every_post_call_site_declares_intent` walks the package with `ast` and
fails until every `method="POST"` call either passes `idempotent=` or is listed
as a known write with a reason, so the next POST-shaped read cannot quietly
lose its retries the same way.

#122 -- `normalize_futures_slug` raised a builtin `ValueError`, outside the SDK
hierarchy. That predates #111, but #111 turned 18 live catalog codes from
"resolved to a DIFFERENT instrument" into "raises" -- the right call -- so user
code written as `except OilPriceAPIError: fall_back()` went from a silently
wrong answer to an uncaught crash. #111's own comment argues a refusal is
recoverable; it is only recoverable if it is catchable.

New `FuturesContractError(ValidationError, ValueError)`: catchable through the
documented base class, and still a `ValueError` so existing callers are
unaffected. It renders its own message, which lists every valid slug and
contract code, so the guidance survives regardless of #117's merge order.

Tests: tests/unit/test_url_allows_space.py (10),
tests/unit/test_read_shaped_post_retries.py (12),
tests/unit/test_futures_contract_error.py (26). Proven red against
`origin/main` source: 28 failed / 23 passed.

Suite: 112 failed / 681 passed / 63 skipped, against a measured baseline of
112 failed / 630 passed / 63 skipped on clean main (the 112 are pre-existing,
all missing pytest-asyncio and respx in the environment).

Note for reviewers: `resources/alerts.py` and `resources/diesel.py` are CRLF
files in this repo. Their line endings are preserved -- the diff is 5 and 6
lines, not the whole file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo

* style: sort FuturesContractError into the __init__ import block

ruff's isort rule failed CI: the new export was inserted before
ValidationError rather than in alphabetical position after DataNotFoundError.
No behaviour change.

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>
karlwaldman added a commit that referenced this pull request Sep 13, 2026
…month slug (#128) (#136)

* fix(futures): stop answering a dated contract with the generic front-month slug (#128)

`normalize_futures_slug` stripped a trailing digit run unconditionally, so a
DATED contract was silently answered with the GENERIC slug -- a different
instrument:

    WTI2026   -> 'wti'          BRENT2027 -> 'brent'
    NG2026    -> 'natural-gas'  TTF2026   -> 'ttf-gas'

CONTRACT_CODE_TO_SLUG holds no key containing a digit, so every one of those
came from the strip rather than a real mapping. The request went out as
/v1/futures/wti/curve, returned data, and nothing told the caller their year
had been discarded.

This is the other half of what #111 fixed. #111 removed the guess from the
separator-splitting branch (WTI_MIDLAND_USD now raises rather than resolving to
wti); the digit strip on the very next line still guessed.

An order marker is now bounded to one or two digits -- the same bound
_is_month_or_order_suffix already implies for the separator branch, and wide
enough for CL1, CL2, CL12 and the TradingView CL1!/CL.1 forms. Three or more
digits is a year or an unknown code and falls through to the existing
FuturesContractError refusal.

Verified against the live catalog on 2026-09-13: of 604 codes, none has a
trailing digit run at all, so the narrowing refuses nothing that exists today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo

* test(futures): mock the transport the way this repo already does

respx is not in the [dev] extra, so the two wire tests failed CI with
ModuleNotFoundError on every Python version. Patch httpx.Client.request /
httpx.AsyncClient.request instead, matching tests/unit/test_diesel_envelope.py,
rather than adding a test dependency for two assertions.

Same assertions, same red. Adds a control that CL1! still reaches
/v1/futures/wti, so the test proves the refusal is specific to a dated code and
not a blanket break.

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.

1 participant