Skip to content

feat(fx): add resilient provider caching and stale-data semantics - #141

Open
woahwhattheheck wants to merge 1 commit into
RemitFlow:mainfrom
woahwhattheheck:latch/remitflow-129-fx-cache
Open

woahwhattheheck wants to merge 1 commit into
RemitFlow:mainfrom
woahwhattheheck:latch/remitflow-129-fx-cache

Conversation

@woahwhattheheck

Copy link
Copy Markdown

Closes #129

Problem

Provider outages can block transfers, or worse, let an expired exchange rate be treated as a current quote. The previous rate path read a static table with no TTL, no fallback, no freshness signal, and no quote identity on transfers.

What this PR does

Acceptance criterion How it is met
Bounded cache TTL fxCacheService stores one USD-cross snapshot with FX_CACHE_TTL_MS (default 30s)
Provider fallback Deterministic primary → fallback walk in fxProviders.fetchWithFallback
Freshness metadata Rates and quotes expose freshness.{status,stale,providerId,fetchedAt,expiresAt,ageMs,...}
Quote versioning Every quote gets quoteId + monotonic quoteVersion, stored for later binding
Explicit stale/error policies reject_stale (transfers) vs allow_stale (display); codes QUOTE_STALE, QUOTE_EXPIRED, FX_PROVIDERS_DOWN, FX_REFRESH_IN_PROGRESS
Stampede prevention Sync singleflight: re-entrant refresh throws FX_REFRESH_IN_PROGRESS or serves within-grace stale under allow_stale — never a second provider call
Stale quotes visible and unusable outside policy Quotes carry stale: true; assertUsable / transfer binding refuse them under reject_stale
Fallback deterministic Ordered provider registry; test asserts ['primary','fallback'] attempt order
Quote identity bound to transfer creation Transfers record quoteId, quoteVersion, rateProvider, rateFetchedAt, rateStale. Optional client quoteId must match amount/currencies; omitting it mints a fresh quote and still binds

Design tradeoffs

  • Sync singleflight, not async promises. The transfer path is synchronous today (same shape as the idempotency suite). Stampede coverage re-enters mid-provider-fetch rather than inventing a fake race.
  • Display may see stale; transfers may not. Outage UX for GET /api/rates and GET /api/quote can surface a visibly-stale snapshot within FX_STALE_GRACE_MS. Transfer pricing defaults to reject_stale so an outage cannot silently price a remittance. Opt-in: FX_ALLOW_STALE_TRANSFERS=true.
  • Compat: quoteId is optional on POST /api/transfers. Existing clients keep working; every transfer still receives bound quote provenance. Passing quoteId is the path that locks the sender to the quote they saw.
  • In-process cache/maps. Same durability boundary as the rest of the demo store. Swapping providers for a live oracle does not change the HTTP/contract surface.

Compatibility impact

  • Additive response fields on rates/quotes/transfers (freshness, quoteId, quoteVersion, …).
  • Optional request field quoteId on transfer create (validated when present).
  • No required header/body changes for existing clients.
  • Rate list shape remains { base, rates: [...] } with freshness added alongside.

Tests

test/fxCache.test.js (16) covers:

  • TTL hit / expiry refresh
  • Primary failure → fallback
  • All providers down → 503 under reject_stale; visibly-stale under allow_stale
  • Deterministic fallback order
  • Stampede (re-entrant refresh does not double-fetch)
  • Quote identity + freshness visibility
  • Stale / expired quotes blocked from transfer pricing (regression for “stale mistaken for current”)
  • Quote binding + mismatch rejection
  • Compat mint-and-bind without client quoteId
  • Outage blocks transfer pricing
  • Rate list freshness surface

test/config.test.js covers FX env defaults/overrides.

Verification

npm test
# tests 274
# pass  274
# fail  0

Baseline on upstream/main was 256. No unrelated tests were skipped, deleted, or weakened.

Out of scope

  • Live third-party FX HTTP clients (adapters are injectable; defaults wrap the existing rate table)
  • Cross-process / Redis cache
  • Unrelated service or user-flow rewrites

Bounded TTL cache with primary→fallback providers, freshness metadata,
quote versioning, stampede-safe singleflight, and reject_stale transfer
pricing so outages cannot silently price on expired rates. Transfers bind
quote identity (quoteId/quoteVersion) at creation.

Closes RemitFlow#129
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.

feat(backend): add resilient FX provider caching and stale-data semantics

1 participant