Skip to content

fix(#8): enrich 404 "Budget not found" with unit hint, bump 0.2.3#9

Merged
amavashev merged 2 commits into
mainfrom
fix/issue-8-unit-mismatch-diagnostic
Apr 10, 2026
Merged

fix(#8): enrich 404 "Budget not found" with unit hint, bump 0.2.3#9
amavashev merged 2 commits into
mainfrom
fix/issue-8-unit-mismatch-diagnostic

Conversation

@amavashev
Copy link
Copy Markdown
Contributor

Summary

Fixes #8 — a misleading 404 NOT_FOUND "Budget not found for provided scope: tenant:rider" that was actually a unit mismatch, not a scope miss.

Root cause. The protocol spec defines Balance as "Ledger state for a single (scope, unit) balance" (cycles-protocol-v0.yaml line 667), so a single scope may hold multiple budgets keyed by unit. The reference server's reserve.lua implements this by keying budgets as "budget:" .. scope .. ":" .. estimate_unit. When a reservation targets a scope that has an ACTIVE budget in one unit (e.g. USD_MICROCENTS) but is sent in a different unit (e.g. TOKENS), the script returns BUDGET_NOT_FOUND and the server maps it to HTTP 404. The raw message reads like a scope-lookup miss, which led users to believe the scope didn't exist.

Fix. create_reservation, create_reservation_with_metadata, decide, and create_event post-process errors through a new enrich_budget_not_found(err, unit) helper that detects the exact server marker and rewrites Error::Api.message to include the unit that was sent plus a one-line explanation of the (scope, unit) indexing invariant. All other Error::Api fields (status, code, request_id, retry_after, details) are preserved unchanged, so error classification, retry logic, request_id correlation, and downstream pattern matching behave identically. Non-matching 404s pass through untouched.

Example enriched error for the issue's repro:

Api { status: 404, code: Some(NotFound),
      message: "Budget not found for provided scope: tenant:rider
                (request was sent with unit=TOKENS; verify an ACTIVE
                 budget exists at this scope AND unit — the server
                 indexes budgets by (scope, unit), so a mismatched
                 unit surfaces as a 404 NOT_FOUND)",
      request_id: Some("..."), ... }

What's in this PR

  • src/client.rsenrich_budget_not_found helper + 5 unit tests covering happy path, wire-format unit names (USD_MICROCENTS not UsdMicrocents), non-matching messages, non-404 status, and non-Api error kinds. Wired into the 4 request methods that carry an Amount.
  • tests/client_test.rs — 4 new wiremock integration tests covering reserve/decide/event enrichment end-to-end (including request_id preservation) and a pass-through test that proves an unrelated 404 is not rewritten.
  • Docs (fix(ci): pass rust-versions explicitly to avoid MSRV mismatch #3 of the proposal)Amount (src/models/common.rs), WithCyclesConfig::new (src/lifecycle.rs), examples/with_cycles_usage.rs, and README Quick Start all note the (scope, unit) invariant so new users don't hit this.
  • VersionCargo.toml 0.2.20.2.3; Cargo.lock updated to match.
  • CHANGELOG.md — 0.2.3 entry with Fixed + Docs sections, links to issue [Bug] Rust client returns 404 for a budget that exists at the given scope #8.
  • AUDIT.mdIssues Found & Resolved (0.2.3) entry with direct spec citations (lines 667, 56, 1187–1200). Test Coverage table refreshed from a clean cargo tarpaulin run (previous table had stale numbers) — new totals 472/494 = 95.55%, above the 95% project threshold.

Spec compliance

Reviewed against cycles-protocol-v0.yaml v0.1.24. Two independent passes (manual + Explore subagent) found zero client-side issues:

  • ErrorResponse.message has no normative content constraint — client-side rewriting is wire-transparent.
  • Preserving status: 404, code: Some(ErrorCode::NotFound) is consistent with the spec's definition of NOT_FOUND.
  • The (scope, unit) invariant my docs claim is spec-normative (line 667), not just server trivia.

The audit did surface two server-side spec gaps that are out of scope for this PR (follow-up issue incoming against cycles-server):

  1. POST /v1/reservations documents responses 200, 400, 401, 403, 409, 500 (lines 1187–1200) — no 404 is documented, yet the server returns one here.
  2. Spec line 56 requires UNIT_MISMATCH → HTTP 400 for commit and event, but the analogous rule for reserve is under-specified; the server uses 404 NOT_FOUND instead of 400 UNIT_MISMATCH.

This client change handles the out-of-spec server response defensively; the root cure belongs on the server.

Test plan

  • cargo build — clean
  • cargo test — 131 running tests pass (43 lib unit + 30 wiremock client + 18 models + 10 error + 10 config + 5 lifecycle + 4 guard + 2 response + 1 retry + 8 doc); 12 live-server tests ignored as usual
  • cargo test --lib enrich_budget_not_found — 5/5 new unit tests pass
  • cargo clippy --all-targets -- -W clippy::all — no new warnings from this change (pre-existing {:?} format-string hints untouched)
  • cargo fmt --check — clean
  • cargo tarpaulin --out Stdout --ignore-tests -- --skip live95.55% overall, client.rs 137/143 = 95.80%
  • Manual verification against a live cycles-server with a mismatched-unit reservation (deferred to reviewer; the wiremock tests exercise the exact server error shape)

Follow-ups

  • Open a cycles-server issue for the two spec gaps identified above.
  • Server-side fix would ideally have reserve.lua distinguish "no budget at this scope" from "budget exists at this scope in a different unit" and return 400 UNIT_MISMATCH (which already exists in ErrorCode and is used for commit/event) with the actual stored unit(s).

Closes #8.

The server indexes budgets by the composite key (scope, unit) in
reserve.lua, so a scope with an ACTIVE budget in one unit (e.g.
USD_MICROCENTS) surfaces as HTTP 404 NOT_FOUND "Budget not found for
provided scope: tenant:rider" when the client sends a reservation in a
different unit (e.g. TOKENS). The raw message reads like a scope-lookup
miss, which led users to believe the scope didn't exist (issue #8).

create_reservation, create_reservation_with_metadata, decide, and
create_event now post-process errors through enrich_budget_not_found,
which detects the server's exact 404 marker and rewrites the message
to include the unit that was sent plus a one-line explanation of the
(scope, unit) indexing invariant. Non-matching 404s and other error
kinds pass through unchanged.

Amount, WithCyclesConfig::new, the with_cycles_usage example, and
README Quick Start document the invariant so new users don't hit it.

Adds 5 unit tests (enrichment helper) and 4 wiremock integration
tests (reserve, decide, event enrichment + non-matching 404 pass-
through). Coverage 95.55% (>=95%). Issue #8.
Strengthens the 0.2.3 audit entry with direct citations to
cycles-protocol-v0.yaml:
- line 667 ("Ledger state for a single (scope, unit) balance")
  establishes that the (scope, unit) composite is spec-normative,
  not just server implementation detail.
- line 56 documents the UNIT_MISMATCH rule for commit/event but
  leaves reserve unit-mismatch semantics under-specified.
- lines 1187-1200 show POST /v1/reservations responses: 200, 400,
  401, 403, 409, 500 - no 404 documented, flagging the server's
  404 as out-of-spec (to be filed against cycles-server).

Also clarifies that the client preserves all Error::Api fields
other than message (status, code, request_id, retry_after,
details) so error classification, retry logic, and correlation
behave identically.

Refreshes the Test Coverage table from a clean tarpaulin run
(95.55%, 472/494 lines) and corrects the test count breakdown
(143 tests: 131 running + 12 live ignored; previous audit's
"141 total" did not sum correctly).
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.

[Bug] Rust client returns 404 for a budget that exists at the given scope

1 participant