fix(pricing): exempt /v1/pricing from the rate-limit floor and cache it in the BFF (BUNYIP-518) #517

Merged
longjacksonle merged 1 commit from fix/BUNYIP-518-pricing-ratelimit-floor-exempt into main 2026-08-12 05:49:45 +02:00

Root cause (candidate 1, confirmed from code)

With the switch on and every tier resolving, /pricing 404'd and its nav/footer links stayed hidden. /v1/pricing was under the unauthenticated rate-limit floor (RateLimitConfig::API_UNAUTH = 20 req/60s per IP) and was not in rate_limit_floor::EXEMPT_PATHS (a test even asserted it was NOT exempt). bunyip-web::public_ctx fetches /v1/pricing on EVERY public page render to decide whether the /pricing links are shown, so a few page loads from one browser exhausted that visitor's per-IP bucket. The 429 was swallowed by calls::pricing(..).unwrap_or_else(_ => PricingResponse::default()) into an unpublished payload, which 404s the route and hides its links, while the admin Pricing tiers page looked healthy and nothing hit the logs. That "looks like it never works, nothing in the logs" is exactly why the eliminated candidates (switch, unmapped standard_price_id, stale Arc<RwLock<TierConfig>>) all read fine. Recorded on the issue.

Fix

  • /v1/pricing exempt from the floor (EXEMPT_PATHS, with a comment): it is public, read-only, already TTL-cached server-side (PricingCache), and fetched on every BFF render, so the per-IP floor only ever hurt real browsing.
  • Short-TTL cache in bunyip-web (pricing_cache::PricingCache, 30s, in AppState): several public renders coalesce into at most one upstream call instead of one per render, removing the amplification permanently.
  • No silent unpublish: a fetch failure is logged with the HTTP status and target type; the cache serves the last payload it read rather than flipping to unpublished for a transient 429. Only a cold failure with nothing cached falls back to the unpublished default. Link visibility and the /pricing page body read the same cached payload, so they stay consistent (no link to a 404).
  • update_stripe_config already invalidates the server-side PricingCache (BUNYIP-515).

Also: repairs a main build-break

origin/main does not currently compile its tests: BUNYIP-517's (#516) tier-derivation price() fixture merged after BUNYIP-513 (#515) bumped dunite-stripe, which added a required recurring_interval_count field; on the combined tree the fixture is missing it. This PR adds the field (the still-open #514 carries the identical one-liner, so they merge cleanly). Flagging because main CI is red until one of them lands.

Tests

  • renders_within_ttl_coalesce_to_one_fetch: N renders inside the TTL make one upstream call (the render that used to trip the floor never fetches).
  • a_429_does_not_unpublish_a_previously_published_page: an expired-then-429 render serves the last published payload (BUNYIP-518 AC: a 429 must not silently unpublish).
  • a_cold_failure_falls_back_to_unpublished: nothing cached + failure -> unpublished default, logged, no panic.
  • probes_and_the_stripe_webhook_are_exempt / ordinary_endpoints_are_capped assert the floor exemption both ways.
  • just check-container green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3

## Root cause (candidate 1, confirmed from code) With the switch on and every tier resolving, `/pricing` 404'd and its nav/footer links stayed hidden. `/v1/pricing` was under the unauthenticated rate-limit floor (`RateLimitConfig::API_UNAUTH` = 20 req/60s per IP) and was not in `rate_limit_floor::EXEMPT_PATHS` (a test even asserted it was NOT exempt). `bunyip-web::public_ctx` fetches `/v1/pricing` on EVERY public page render to decide whether the `/pricing` links are shown, so a few page loads from one browser exhausted that visitor's per-IP bucket. The 429 was swallowed by `calls::pricing(..).unwrap_or_else(_ => PricingResponse::default())` into an unpublished payload, which 404s the route and hides its links, while the admin Pricing tiers page looked healthy and nothing hit the logs. That "looks like it never works, nothing in the logs" is exactly why the eliminated candidates (switch, unmapped `standard_price_id`, stale `Arc<RwLock<TierConfig>>`) all read fine. Recorded on the issue. ## Fix - **`/v1/pricing` exempt from the floor** (`EXEMPT_PATHS`, with a comment): it is public, read-only, already TTL-cached server-side (`PricingCache`), and fetched on every BFF render, so the per-IP floor only ever hurt real browsing. - **Short-TTL cache in bunyip-web** (`pricing_cache::PricingCache`, 30s, in `AppState`): several public renders coalesce into at most one upstream call instead of one per render, removing the amplification permanently. - **No silent unpublish**: a fetch failure is logged with the HTTP status and target type; the cache serves the last payload it read rather than flipping to unpublished for a transient 429. Only a cold failure with nothing cached falls back to the unpublished default. Link visibility and the `/pricing` page body read the same cached payload, so they stay consistent (no link to a 404). - `update_stripe_config` already invalidates the server-side `PricingCache` (BUNYIP-515). ## Also: repairs a main build-break `origin/main` does not currently compile its tests: BUNYIP-517's (#516) tier-derivation `price()` fixture merged after BUNYIP-513 (#515) bumped `dunite-stripe`, which added a required `recurring_interval_count` field; on the combined tree the fixture is missing it. This PR adds the field (the still-open #514 carries the identical one-liner, so they merge cleanly). Flagging because main CI is red until one of them lands. ## Tests - `renders_within_ttl_coalesce_to_one_fetch`: N renders inside the TTL make one upstream call (the render that used to trip the floor never fetches). - `a_429_does_not_unpublish_a_previously_published_page`: an expired-then-429 render serves the last published payload (BUNYIP-518 AC: a 429 must not silently unpublish). - `a_cold_failure_falls_back_to_unpublished`: nothing cached + failure -> unpublished default, logged, no panic. - `probes_and_the_stripe_webhook_are_exempt` / `ordinary_endpoints_are_capped` assert the floor exemption both ways. - `just check-container` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3
fix(pricing): exempt /v1/pricing from the rate-limit floor and cache it in the BFF (BUNYIP-518)
All checks were successful
E2E / PR gate (pull_request) Successful in 24s
Check / fmt + clippy + build + tests (pull_request) Successful in 10m41s
c84cdcf8d5
With the switch on and every tier resolving, /pricing returned 404 and its nav and footer links stayed hidden. Root cause: /v1/pricing was under the unauthenticated rate-limit floor (RateLimitConfig::API_UNAUTH, 20 requests / 60s per IP) and was not in rate_limit_floor::EXEMPT_PATHS. bunyip-web::public_ctx fetches /v1/pricing on every public page render to decide whether the /pricing links are shown, so a handful of page loads from one browser exhausted that visitor's per-IP bucket. The resulting 429 was swallowed into an unpublished payload, which 404s the route and hides its links, while nothing looked wrong on the admin Pricing tiers page and nothing hit the logs.

/v1/pricing is now exempt from the floor: it is public, read-only, already TTL-cached server-side (PricingCache), and fetched on every BFF render, so the per-IP floor only ever hurt real browsing. bunyip-web also caches the payload for a short TTL (PricingCache in bunyip-web), so several public page renders coalesce into at most one upstream call rather than one per render, which removes the amplification permanently. On a fetch error the cache logs the HTTP status and the target type and serves the last payload it read rather than flipping the page to unpublished for a transient failure; only a cold failure with nothing cached falls back to the unpublished default. The cache and its links stay consistent because both the link visibility and the /pricing page body read the same cached payload. update_stripe_config already invalidates the server-side PricingCache (BUNYIP-515).

This also repairs a main build-break: BUNYIP-517's tier-derivation test fixture merged after BUNYIP-513 bumped dunite-stripe, which added a required recurring_interval_count field the fixture never got on the combined tree.

Tests: the cache coalesces N renders into one upstream call; a 429 after a good fetch serves the last published payload (a 429 must not silently unpublish); a cold failure falls back to unpublished; and the floor exemption is asserted both ways.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-08-12 05:27:50 +02:00
longjacksonle force-pushed fix/BUNYIP-518-pricing-ratelimit-floor-exempt from c84cdcf8d5
All checks were successful
E2E / PR gate (pull_request) Successful in 24s
Check / fmt + clippy + build + tests (pull_request) Successful in 10m41s
to 026ad66949
All checks were successful
E2E / PR gate (pull_request) Successful in 30s
Check / fmt + clippy + build + tests (pull_request) Successful in 9m51s
Create release / Create release from merged PR (pull_request) Has been skipped
2026-08-12 05:39:51 +02:00
Compare
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-08-12 05:41:45 +02:00
longjacksonle deleted branch fix/BUNYIP-518-pricing-ratelimit-floor-exempt 2026-08-12 05:49:45 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!517
No description provided.