Skip to content

feat(fuel-surcharge): typed LTL and parcel fuel-surcharge clients (#101) - #144

Merged
karlwaldman merged 2 commits into
mainfrom
feat/101-fuel-surcharge
Sep 13, 2026
Merged

karlwaldman merged 2 commits into
mainfrom
feat/101-fuel-surcharge

Conversation

@karlwaldman

@karlwaldman karlwaldman commented Sep 13, 2026

Copy link
Copy Markdown
Member

Closes #101.

What

client.fuel_surcharge on both OilPriceAPI and AsyncOilPriceAPI, covering all six routes in V1::FuelSurchargeController (verified on oilpriceapi-api origin/main, config/routes.rb scope fuel-surcharge):

Method Route Returns
list() GET /v1/fuel-surcharge List[FuelSurchargeRate]
latest(carrier) GET /v1/fuel-surcharge/{carrier}/latest FuelSurchargeRate
history(carrier, page=, per_page=) GET /v1/fuel-surcharge/{carrier}/history FuelSurchargeHistoryPage
parcel_list() GET /v1/fuel-surcharge/parcel List[ParcelFuelSurchargeCarrier]
parcel_latest(carrier) GET /v1/fuel-surcharge/parcel/{carrier}/latest ParcelFuelSurchargeCarrier
parcel_latest_rate(carrier, service_level) same route with ?service_level= FuelSurchargeRate
parcel_history(carrier, service_level, page=, per_page=) GET /v1/fuel-surcharge/parcel/{carrier}/history FuelSurchargeHistoryPage

The parcel latest route returns two different shapes depending on whether service_level is sent, so the SDK exposes two methods with distinct return types instead of a union the caller must sniff.

Sync and async share one module (oilpriceapi/_fuel_surcharge_common.py) for path building, validation and parsing.

Wire shapes typed against (production, 2026-09-13, test key)

Rate object (rate_payload in the controller). Every key is always emitted except service_level, which is emitted only for parcel rows:

{"carrier":"odfl","carrier_name":"Old Dominion Freight Line","mode":"ltl","surcharge_percent":46.32,
 "effective_date":"2026-09-09","doe_diesel_price":5.599,"diesel_band":null,
 "source":"https://www.odfl.com/us/en/resources/fuel-surcharge.html","retrieved_at":"2026-09-08T16:10:10Z"}
  • diesel_band is null or {"min":5.57,"max":5.6} (Southeastern Freight); each bound is nullable in the DB.
  • doe_diesel_price is null for ABF, TForce and every parcel row.
  • History: {"history":[rate...],"meta":{"page":2,"per_page":3,"total_count":6,"total_pages":2}}
  • Parcel carrier: {"carrier":"ups","carrier_name":"UPS","mode":"parcel","service_levels":[rate+service_level...]}
  • Errors use {"status":"fail","data":{...}}:
    • unknown carrier: 404 with error + covered_carriers + hint
    • reserved carrier (fedex-freight): 404 with error + covered_carriers
    • no data (e.g. unknown parcel service level): 404
    • parcel history without service_level: 400 with available_service_levels
  • Unauthenticated: 401 canonical {"error":{"code":"UNAUTHORIZED",...}}.

Typing and fail-closed decisions

  • effective_date is a datetime.date. Only a YYYY-MM-DD string is accepted (pydantic lax mode would otherwise turn 0 into 1970-01-01).
  • retrieved_at is a timezone-aware datetime; a naive timestamp is rejected.
  • Numbers are strict: "46.32" and true are rejected. source must be non-empty.
  • Keys the API always sends are required, including the nullable ones. A missing key raises OilPriceAPIError(code="MALFORMED_RESPONSE") with raw_body kept; a null stays None.
  • Rows are checked against the route: an LTL route returning a parcel row, the wrong carrier, or the wrong service level is malformed.
  • Carrier and service level must match ^[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9])?$ before any request is built.
  • page >= 1 and 1 <= per_page <= 100 are enforced locally. The API silently clamps: verified live, per_page=500&page=0 returned meta {"page":1,"per_page":100}. The SDK refuses rather than return a different page than the one requested.
  • exceptions.error_from_response now also reads covered_carriers and available_service_levels into error.suggestions. Before, those lists survived only in raw_body.

TDD evidence

Red (tests written first; oilpriceapi had no fuel-surcharge models or resource):

ImportError while importing test module '/Users/karlwaldman/code/py-101/tests/unit/test_fuel_surcharge_resource.py'.
tests/unit/test_fuel_surcharge_resource.py:30: in <module>
    from oilpriceapi import (
E   ImportError: cannot import name 'FuelSurchargeDieselBand' from 'oilpriceapi' (/Users/karlwaldman/code/py-101/oilpriceapi/__init__.py)
=========================== short test summary info ============================
ERROR tests/unit/test_fuel_surcharge_resource.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.36s ===============================

The first implementation run was also red on a real gap: parcel_history("ups", None) was not refused locally.

FAILED tests/unit/test_fuel_surcharge_resource.py::test_invalid_service_level_is_refused_locally[sync-None]
FAILED tests/unit/test_fuel_surcharge_resource.py::test_invalid_service_level_is_refused_locally[async-None]
======================== 2 failed, 145 passed in 1.84s =========================

Green:

$ python -m pytest tests/unit/test_fuel_surcharge_resource.py -q --no-cov
============================= 147 passed in 0.69s ==============================

All 147 tests run the real sync and async clients against a mocked transport (httpx.Client.request / httpx.AsyncClient.request) and assert the path and params actually sent. They cover:

  • success shapes for every method
  • pagination meta preserved
  • nulls preserved
  • unknown carrier, reserved carrier and no-data 404s
  • 400 missing service level
  • 401, 402, 403, 429 (not replayed), timeout
  • malformed 200s: every missing field, bad values, bad envelopes, mode/carrier/service-level mismatches
  • non-JSON 200
  • local refusal of bad slugs and out-of-range pagination with zero requests sent

Success fixtures are trimmed production captures; the 402/403/429 bodies are canonical envelopes used only to test status mapping.

Full suite, ruff, mypy, storefront:

$ python -m pytest tests/ -q --no-cov
FAILED tests/integration/test_demo_contract.py::TestDemoPricesContract::test_prices_envelope_and_parsing
FAILED tests/integration/test_demo_contract.py::TestDemoPricesContract::test_prices_meta_demo_mode
FAILED tests/integration/test_demo_contract.py::TestDemoCommoditiesContract::test_commodities_codes_filter
=========== 3 failed, 1376 passed, 65 skipped, 30 warnings in 6.40s ============
$ ruff check oilpriceapi/
All checks passed!
$ mypy oilpriceapi/ --ignore-missing-imports
Success: no issues found in 51 source files
$ python scripts/validate_storefront_claims.py
validated 72 public surfaces

Compared with the main baseline (1229 passed / 3 failed / 63 skipped):

  • +147 new unit tests
  • +2 skipped: the new live tests, which have no key in that run
  • The same 3 failures: the live /v1/demo contract tests, which are untouched by this PR.

Review follow-up: local refusals are ValidationError, not ValueError (ea37a13)

No code change was needed. Every local input refusal on the new methods raises the repo convention, the same way _url._reject does:

ValidationError(message, field=<arg name>, value=<offending value>, status_code=None)
  • The carrier and service_level guards are validate_slug in oilpriceapi/_fuel_surcharge_common.py.
  • The page and per_page guards are _positive_int in the same file.
  • status_code=None because no request was sent.
  • There are no raw raise ValueError refusals in the non-model PR code (count: 0).

The four raise ValueError added in oilpriceapi/models.py are pydantic field_validators (effective_date, retrieved_at). Pydantic requires validators to raise ValueError. They never reach the caller as ValueError: _build converts them to OilPriceAPIError(code="MALFORMED_RESPONSE"), which the malformed-200 tests assert.

No existing public method changed its exception type. All seven fuel-surcharge methods are new, so no dual-base subclass is needed.

What changed is the tests. Before, only latest() asserted status_code is None for a bad carrier. Now these all go through _assert_local_refusal, which checks type(error) is ValidationError, status_code is None, is_client_error is False, field, value, and zero transport calls, on sync and async:

  • every method that takes a carrier: 5 methods × 7 bad values
  • both methods that take a service level: 2 methods × 4 bad values
  • LTL and parcel history pagination: 6 bad values each

Red-capability proof: the slug guard was temporarily changed to raise ValueError(f"{field} is not a valid slug"), run, then restored (restored diff: 0 lines).

$ python -m pytest tests/unit/test_fuel_surcharge_resource.py -q --no-cov -k refused_locally
FAILED tests/unit/test_fuel_surcharge_resource.py::test_invalid_carrier_is_refused_locally[sync-latest-]
FAILED tests/unit/test_fuel_surcharge_resource.py::test_invalid_carrier_is_refused_locally[sync-latest-   ]
FAILED tests/unit/test_fuel_surcharge_resource.py::test_invalid_carrier_is_refused_locally[sync-latest-odfl/latest]
FAILED tests/unit/test_fuel_surcharge_resource.py::test_invalid_carrier_is_refused_locally[sync-latest-odfl?x=1]
...
E           ValueError: carrier is not a valid slug
====================== 86 failed, 137 deselected in 2.47s ======================

After restore:

$ python -m pytest tests/unit/test_fuel_surcharge_resource.py -q --no-cov
============================= 223 passed in 0.93s ==============================
$ python -m pytest tests/ -q --no-cov
FAILED tests/integration/test_demo_contract.py::TestDemoPricesContract::test_prices_envelope_and_parsing
FAILED tests/integration/test_demo_contract.py::TestDemoPricesContract::test_prices_meta_demo_mode
FAILED tests/integration/test_demo_contract.py::TestDemoCommoditiesContract::test_commodities_codes_filter
=========== 3 failed, 1452 passed, 65 skipped, 30 warnings in 6.69s ============
$ ruff check oilpriceapi/
All checks passed!
$ mypy oilpriceapi/ --ignore-missing-imports
Success: no issues found in 51 source files
$ python scripts/validate_storefront_claims.py
validated 72 public surfaces

Against the main baseline (1229 / 3 / 63), this branch now adds 223 unit tests and 2 skipped live tests. The 3 failures are the same live /v1/demo contract tests.

Live smoke: RAN, passed

$ OILPRICEAPI_TEST_KEY=<from ~/.claude/.env> python -m pytest tests/integration/test_live_fuel_surcharge.py -v --no-cov
tests/integration/test_live_fuel_surcharge.py::test_ltl_list_latest_and_history_live PASSED [ 50%]
tests/integration/test_live_fuel_surcharge.py::test_parcel_list_and_latest_rate_live PASSED [100%]
============================== 2 passed in 6.47s ===============================

Read-only. The carrier and service level come from the list responses, not hard-coded.

Docs

  • README.md: new "Carrier Fuel Surcharges" section
  • docs/reference/resources.md
  • examples/fuel_surcharge.py
  • CHANGELOG.md under ## [Unreleased]

No version bump, no tag. No snippet-manifest entry: tests/test_snippet_manifest.py executes every manifest snippet against a fixture server that only serves /v1/prices/*.

Premise corrections (also commented on #101)

  • No entitlement gate. The controller header and routes comment record a 2026-07-17 decision: every fuel-surcharge route is available on every tier including free. 402/403 are tested as generic status mapping, not as a real gate.
  • No machine-readable capabilities file exists in this repo. Nothing was invented.
  • Release was not cut, per instructions.

🤖 Generated with Claude Code

https://claude.ai/code/session_015ao5paex73xXvuM424Libo

Add client.fuel_surcharge on OilPriceAPI and AsyncOilPriceAPI covering all
six /v1/fuel-surcharge routes, with FuelSurchargeRate,
FuelSurchargeHistoryPage and ParcelFuelSurchargeCarrier models typed from
production payloads captured 2026-09-13.

- effective_date is a date, retrieved_at a tz-aware datetime; source,
  nullable doe_diesel_price and diesel_band are preserved as sent.
- A success body missing a field the API always sends raises
  OilPriceAPIError(code="MALFORMED_RESPONSE"); nothing is defaulted.
- Carrier slugs, service levels and pagination are validated before the
  request; out-of-range page/per_page are refused because the API clamps
  them silently (verified live: per_page=500&page=0 -> meta page 1/100).
- covered_carriers / available_service_levels from 400/404 bodies populate
  error.suggestions.

Co-Authored-By: Claude Opus 5 <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: ff586b14-a4bc-4f55-b112-ff04ca9c2f46


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.

…lue, status_code=None) (#101)

Review follow-up on #144. The carrier, service_level, page and per_page
guards already raised ValidationError with status_code=None (the _url._reject
convention); the tests only asserted that for carrier on latest(). Every
method that takes a carrier or service level, and both history routes'
pagination, now assert the exact type, status_code None, is_client_error
False, field, value and zero transport calls, on sync and async.

Red-capability: swapping the slug guard to a raw ValueError fails 86 of 86
selected refusal tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
@karlwaldman
karlwaldman merged commit 8bd6b50 into main Sep 13, 2026
7 checks passed
@karlwaldman
karlwaldman deleted the feat/101-fuel-surcharge branch September 13, 2026 20:30
karlwaldman added a commit that referenced this pull request Sep 13, 2026
…ifecycle

Resolves the single conflict in CHANGELOG.md by keeping both sides: the
subscription lifecycle and fuel-surcharge entries under [Unreleased] Added,
with the subscription fixes under Fixed. All other files auto-merged;
client.fuel_surcharge and client.subscriptions remain registered on both
the sync and async clients (verified by instantiating both).

Full suite on the merged tree: 1716 passed / 3 failed (known live demo
429s) / 66 skipped. ruff and mypy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
karlwaldman added a commit that referenced this pull request Sep 13, 2026
…/99-spreads-indicators

Conflicts resolved by keeping every side:
- CHANGELOG.md: #99 spreads/indicators entry, then the #100 and #101
  Added entries and the #100 Fixed entries, all under [Unreleased].
- oilpriceapi/async_client.py: spreads, indicators and fuel_surcharge all
  registered (subscriptions untouched).
- oilpriceapi/async_resources.py: metrics_models import plus main's
  multi-line models import; AsyncFuelSurchargeResource kept whole, followed
  by AsyncSpreadsResource and AsyncIndicatorsResource.

Verified on the merged tree by instantiating both clients: spreads,
indicators, fuel_surcharge and subscriptions and all their methods exist
(88/88). Full suite 1859 passed / 3 failed (known live demo 429s) /
68 skipped; ruff, mypy and storefront validator clean. The .gitignore
fixture exception and all 30 fixtures survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
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][Sprint 1][Growth] Add typed LTL and parcel fuel-surcharge clients

1 participant