Table of Contents

Bodu.Financial.ExchangeRates - Core concepts

This page is the vocabulary the rest of the documentation assumes. Read it once before the getting-started samples or the providers guide, and refer back whenever a term feels imprecise.

Part of the Numerics & Financial topic. For the high-level shape of the package, start with the introduction; the FX vocabulary shared with the core package - provider contracts, resolution policies, provenance - lives in the Bodu.Financial concepts page.

Warm, then look up

A web provider is a materializer: it downloads a feed, upserts every observation into an accumulator, and rebuilds an immutable snapshot (a RateBook wrapped in a FixedDatedRateProvider). Every lookup is answered from that snapshot, so a lookup never touches the network unless the snapshot cannot answer it.

There are three ways to warm the snapshot:

Surface Available on What it loads
Feed-specific warm-up (LoadRangeAsync, PreloadAsync, LoadFeedAsync, LoadEraAsync) Each bulk provider, shaped to its feed A date range, an era, or a whole feed for every quote currency.
LoadPairAsync(from, to, start, end) Every provider, through IPairRateLoader Whatever unit covers the requested pair and window - a single pair on a pair feed, the enclosing feed or era on a bulk feed.
The asynchronous getters (GetRateAsync, GetRatesAsync) Every provider On demand: a single-date lookup fetches the window [date − DefaultLookback, date]; a range read fetches the requested range. Already-covered windows are skipped.

The synchronous getters (GetRate, TryGetRate, GetRates) resolve against the snapshot only. On a miss they return false or throw KeyNotFoundException - unless AllowSynchronousNetworkAccess is enabled (below).

Bulk provider vs pair provider

WebRateProvider is the root base. A bulk provider (RBA, ECB, BoE, IMF) derives from it directly: the feed publishes one base currency against many quotes, so a load brings in every pair at once and the provider tracks coverage per feed, era, or window. Only direct (base→X) and inverse (X→base) pairs resolve; a cross pair such as USD→JPY on the ECB provider is rejected with RateSeriesNotFoundException before any download.

PairWebRateProvider<TSeries> is the pair specialization (Yahoo, OFX, XE, OANDA, Fixer, exchangerate.host, FRED). It implements coverage tracking with a gap-aware per-pair DateRangeCoverage set - a request that straddles an unfetched interior gap is treated as uncovered and fetched - and delegates the actual fetch-and-parse to an IPairRateSource<TSeries>. The TSeries type parameter is the feed-specific series metadata (YahooSeriesInfo, FixerSeriesInfo, …) that GetAvailablePairs() returns.

DefaultLookback

When a single-date asynchronous lookup must fetch on demand, the provider fetches the window ending on the requested date and spanning DefaultLookback (7 days by default on WebRateProviderOptions), so a PreviousWithin resolution has observations to fall back to. Bulk providers override it: the ECB provider uses TimeSpan.Zero because a feed load already brings the surrounding dates.

RateRangeResult

A range read returns a RateRangeResult rather than a bare list, so the caller can tell what was asked for from what was observed:

Member Meaning
FromIsoCode, ToIsoCode The pair as requested.
RequestedStartDate, RequestedEndDate The inclusive window as requested.
Rates The observations found, in ascending date order - also exposed as this[int], Count, and IEnumerable<ExchangeRate> (the result itself is an IReadOnlyList<ExchangeRate>).
IsEmpty true when no observation fell inside the window.
FirstObservedDate, LastObservedDate The observed span, or null when empty.

A window that spans weekends or holidays yields fewer observations than days; an empty result is a normal outcome, not an exception.

History availability

Every web provider implements IHistoricalRateProvider and advertises how far back it can serve rates through HistoryAvailability, a RateHistoryAvailability with three kinds:

Factory Kind Meaning
RateHistoryAvailability.Unbounded Unbounded No declared floor (the base-class default).
RateHistoryAvailability.RollingDays(n) Rolling Only the last n days relative to the current date - OANDA's anonymous endpoint (~180 days), or the ECB provider when only rolling feeds are configured.
RateHistoryAvailability.Since(date) Since Data exists from a fixed epoch - Fixer's 1999-01-01, the ECB full-history feed's 1999-01-04.

GetEarliestAvailable(asOf) and IsAvailable(date, asOf) let a caller - or the caching layer - avoid asking for dates the feed has declared unavailable. Pair providers read the value from WebRateProviderOptions.HistoryAvailability; bulk providers derive it from their feed configuration.

Payload cache vs rate cache

Two caches with different jobs:

Payload cache Rate cache
Lives in This package (IByteCache<TKey>, FileSystemByteCache<TKey>) and the bulk provider packages Bodu.Financial.ExchangeRates.Caching (CachingRateProvider)
Stores The raw bytes of a downloaded file, keyed by download unit (an ECB feed, an RBA era, an IMF report month) Parsed, resolved rates plus the fetched-window coverage, per provider and pair
Purpose Avoid re-downloading immutable history across process restarts Serve repeated lookups without consulting the provider at all; share across processes and machines
Freshness A file older than the options' RefreshInterval is re-downloaded Per-provider expiry, jitter, and refresh-ahead
Configured by EnableDiskCache, CacheDirectory, RefreshInterval on the bulk providers' options AddCachedRateProvider and the IRateCache backends

The payload cache is best-effort: an I/O failure while reading is a miss, a failure while writing is swallowed (and logged when a logger is supplied), so a cache problem never breaks rate retrieval. Pair providers keep no payload cache - each pair-and-window request is a distinct query - and rely on in-memory coverage and, optionally, the rate cache.

Single-flight

SingleFlightCoordinator<TKey> implements request coalescing: when several callers request the same key while a fetch is running, they join the running task instead of starting duplicate work. WebRateProvider.LoadCoalescedAsync(key, load, cancellationToken) wraps it for derived types, and PairWebRateProvider<TSeries> applies it per pair-and-window automatically.

Two properties matter to consumers. The in-flight entry is released as soon as the operation completes, including on failure, so a fault never poisons a key. And the shared operation runs under CancellationToken.None: a caller's token abandons only that caller's wait (it observes OperationCanceledException), never the fetch the other joiners are waiting on.

AllowSynchronousNetworkAccess

Off by default. When enabled, the synchronous getters block to fetch a missing window on demand instead of reporting a miss. Blocking on asynchronous I/O from a thread with a captured SynchronizationContext (a UI thread, classic ASP.NET) can deadlock, so the provider converts that situation into an immediate InvalidOperationException rather than a hang. Enable it only for code that calls the synchronous getters from thread-pool threads, or prefer the asynchronous surface and an explicit warm-up.

Resilience

A provider constructed from options alone builds and owns its HttpClient through RateProviderHttpClientFactory: the options' UserAgent, HttpTimeout (applied as HttpClient.Timeout), and MaxResponseContentBufferSize (a 64 MiB ceiling so a hostile endpoint cannot drive unbounded memory use). There is no retry.

A provider registered through AddWebRateProvider (every Add<Source>ExchangeRates) borrows a named HttpClient from IHttpClientFactory fitted with the standard Polly resilience handler - retry with exponential backoff and jitter, a per-attempt timeout driven from HttpTimeout, a total-request timeout of three times that value, and a circuit breaker. HttpClient.Timeout is set to infinite so the two mechanisms do not compete. Tune or effectively disable the pipeline through the configureResilience callback (Action<HttpStandardResilienceOptions>) on the registration.

Failure modes

Situation Surfaces as Where
A date the snapshot cannot resolve under the lookup options TryGetRate returns false; GetRate / GetRateAsync throw KeyNotFoundException Every provider
A pair a single-base feed does not quote (cross pair) RateSeriesNotFoundException (a KeyNotFoundException) Bulk providers, before any download
An inverted date range (endDate < startDate), a malformed ISO code ArgumentException (ArgumentNullException for a null code) Range and warm-up surfaces
Invalid options (BaseAddress null, non-positive timeout, missing API key, …) ArgumentException from the constructor; with DI, an options-validation failure at startup (ValidateOnStart) Construction / registration
A downloaded payload that cannot be parsed ExchangeRateFormatException (a FormatException) The fetch, propagated to the caller that triggered it
A transport failure (DNS, connection, non-success status) HttpRequestException - on the DI path, after the resilience pipeline has exhausted its retries The fetch
A per-request timeout on an owned client TaskCanceledException (the HttpClient convention) The fetch
A synchronous fetch attempted on a thread with a SynchronizationContext InvalidOperationException Synchronous getters with AllowSynchronousNetworkAccess enabled
Any member after Dispose() ObjectDisposedException Every provider

Fetch failures are logged at DownloadFailedLogLevel (Warning by default) and rethrown; the pair base logs anything other than a transport, I/O, or format failure under a distinct error event so a bug is not mislabelled as a feed problem. Cancellation is never logged.

Lifetimes and HttpClient ownership

A provider is designed to be long-lived: its value is the accumulated snapshot. The DI registration makes it a singleton and exposes the same instance as IDatedRateProvider and IRateProvider (idempotent TryAdd registrations, so the first registered provider wins each contract).

The options-only constructor owns the HttpClient it builds and disposes it with the provider. The constructor that accepts an HttpClient leaves the client's lifetime - and its user agent and timeout - to the caller; that is the shape AddWebRateProvider uses with IHttpClientFactory. Dispose an owned provider when it goes out of scope; after disposal every public member throws ObjectDisposedException, but snapshots already handed out through GetLoadedBook() / GetLoadedSnapshot() stay valid because they are immutable.

Thread safety

A provider is safe for concurrent use. Lookups read a volatile reference to the current immutable snapshot, so they never block behind a fetch and never observe a partially applied load; fetches accumulate under the provider's SyncRoot and publish the new snapshot atomically. Concurrent loads of the same unit coalesce (above). GetLoadedBook() and GetLoadedSnapshot() return immutable instances pinned at call time - later fetches replace, never mutate, them - so they are safe to share across threads and to keep after the provider is disposed. GetAvailablePairs() returns a snapshot array.

Snapshots and export

GetLoadedBook() returns the immutable RateBook of everything fetched so far; GetLoadedSnapshot() returns the same data as a ready-to-query FixedDatedRateProvider. Both are the composable export primitives - rewrap the book with a provider-priority policy, edit a copy through RateBook.ToBuilder(), or hand the snapshot to code that must never touch the network. The providers guide shows the patterns.

Logging

Logging is opt-in: pass an ILogger (or let DI supply one) or the provider uses NullLogger. Each options type exposes five per-event levels - DownloadStartingLogLevel (Debug), DownloadCompletedLogLevel (Information), DownloadFailedLogLevel (Warning), ObservationIngestedLogLevel (Information), and SynchronousNetworkFetchLogLevel (Warning) - so verbosity is tuned per concern without category-wide filters; LogLevel.None suppresses an event entirely.

Where to go next