ecluse
Safe HaskellNone
LanguageGHC2021

Ecluse.Credential.Refresh

Description

The refresh cache expiry / concurrency policy behind a CredentialProvider.

The interesting part of outbound auth is not the cloud call but the policy around it: serve a cached token, refresh it proactively before it expires, never stampede the token API, and stay up across a transient mint outage. That policy is identical for every cloud, so it lives here once, parameterised over a tiny per-cloud rcMint leaf (CodeArtifact's GetAuthorizationToken, an ADC OAuth2 token, …) and an injected rcClock. Only rcMint touches a network; everything else is deterministic, so the whole policy is unit-tested with a fake clock and a fake mint (see docs/architecture/cloud-backends.md → "Credential Provider").

The policy

  • Proactive, background refresh. A token is refreshed when the clock passes a fraction (rcRefreshAt, ~80%) of its lifetime, with rcJitter to desynchronise a cohort of instances, plus a hard floor near expiry. Because the current token stays valid during the refresh, the request hot path never blocks on a mint in the common case — the refresh runs in the background and swaps the token in when it lands.
  • Single-flight. At most one mint is ever in flight per provider (an STM flag), so a cohort of callers crossing the threshold together never stampedes the cloud token API; the rest serve the still-valid cached token.
  • Serve-stale on failure, behind a circuit breaker. A failing mint does not fail the caller while the cached token is still valid — the wrapper keeps serving it and retries later. Repeated failures trip a circuit breaker that fast-fails further mints for a cooldown (rcBreakerCooldown) before a single half-open probe tests recovery, so a sustained outage neither hammers the token API nor adds latency. Only an expired token together with a still-failing mint surfaces as an exception to the caller (the breaker shares its shape with the effectful-rule tier — see docs/architecture/rules-engine.md → "Effectful-rule failure").

A CredentialProvider always backs the mirror-target write; under the default passthrough access strategy that is its only use, so even a fully failed refresh touches only the mirror publish and never the client serve path. Where a mount instead puts a provider on the private-upstream read (the service and service-populated delegated-cache strategies), that dependent operation is a client read, so an exhausted read credential degrades serving. The refresh policy here is identical either way (see docs/architecture/access-model.md → "Credential supply").

The implementation lives in Ecluse.Credential.Refresh.Internal; this module re-exports only the stable surface a caller needs.

Synopsis

Configuration

data RefreshConfig Source #

How a refreshingProvider mints, times, and protects its token. The two effectful leaves (rcMint, rcClock) and the jitter source (rcJitter) are injected so the whole policy is deterministic under test; the rest are policy knobs with sensible defaults in defaultRefreshConfig.

Constructors

RefreshConfig 

Fields

  • rcMint :: IO AuthToken

    The per-cloud token mint — the only part that touches a network. A backend supplies just this leaf; everything else is cloud-agnostic.

  • rcClock :: IO UTCTime

    The clock the policy reads. Injected so refresh timing is testable without real time passing.

  • rcJitter :: IO Double

    A jitter fraction in [0, 1), sampled once per token, that pulls the refresh instant earlier to desynchronise a cohort of instances so they do not all refresh at the same moment.

  • rcRefreshAt :: Double

    The fraction of a token's lifetime at which to refresh, before jitter (the ~80% point). Clamped into [0, 1].

  • rcRefreshFloor :: NominalDiffTime

    A hard floor: never schedule the refresh later than this many seconds before expiry, so a token with a very short lifetime is still refreshed ahead of its deadline rather than served right up to it.

  • rcBreakerThreshold :: Int

    Consecutive mint failures that trip the circuit breaker.

  • rcBreakerCooldown :: NominalDiffTime

    How long the breaker stays open (fast-failing mints) before a single half-open probe is allowed to test recovery.

  • rcBreakerReporter :: BreakerReporter

    The observer the mint breaker reports its state transitions to. Inert by default (noBreakerReporter); the composition root installs the live one.

  • rcRefreshReporter :: RefreshReporter

    The observer each refresh attempt's outcome is reported to. Inert by default (noRefreshReporter); the composition root installs the live one.

defaultRefreshConfig :: RefreshConfig Source #

Sensible defaults for the policy knobs. The caller must still supply the effectful leaves — rcMint and rcClock default to a mint/clock that always fails, so a provider built without wiring them up fails loudly rather than silently serving nothing.

  • refresh at 80% of lifetime (no jitter by default; rcJitter may pull it earlier);
  • a 30-second floor before expiry;
  • breaker trips after 5 consecutive failures, cooling down for 60 seconds.

The refreshing provider

refreshingProvider :: RefreshConfig -> IO CredentialProvider Source #

Build a CredentialProvider that caches a token and refreshes it per the RefreshConfig policy (see the module header). Mints once eagerly to seed the cache, so a provider that cannot mint at all fails here at construction rather than on the first request; thereafter currentToken serves the cache and refreshes behind it.

Telemetry reporters

data RefreshReporter Source #

An observer of a refresh attempt's outcome, so the composition root can record the ecluse.credential.* signals without the refresh policy depending on telemetry. A successful mint reports the freshly minted token's remaining lifetime; a failed mint reports the still-cached token's remaining lifetime (so a sustained outage shows the gauge decaying as repeated failures resample the ageing token). The seconds are Nothing for a token with no expiry. noRefreshReporter is the inert default.

Constructors

RefreshReporter 

Fields

noRefreshReporter :: RefreshReporter Source #

The inert refresh reporter: records nothing on either outcome.

data CredentialReporters Source #

The telemetry observers a refreshing provider records through: the mint circuit breaker's state changes and each refresh attempt's outcome. Bundled so the composition root passes one value to the provider constructors; noCredentialReporters is the inert default the provider carries when telemetry is off (or before the substrate exists).

Constructors

CredentialReporters 

Fields

noCredentialReporters :: CredentialReporters Source #

Inert observers for both signals: the provider records nothing.

Failure

data CredentialError Source #

A failure surfaced from the credential-refresh layer.

The runtime case is BreakerOpen: there is no valid token to serve and a fresh mint is unavailable. A still-valid token is always served instead (the refresh fails silently in the background), so this is reached only on the expired-token path. Whether reaching it can affect a client serve depends on what the credential backs: never under the default passthrough strategy (mirror-write only), but it can where a provider sits on the private-upstream read (see the module header).

The degenerate case is Unconfigured: a RefreshConfig from defaultRefreshConfig was used without supplying an effectful leaf, a wiring fault the default raises loudly rather than silently serving nothing.

Constructors

BreakerOpen

The token has expired and the mint circuit breaker is open, so no mint is attempted; the caller must back off and retry later.

Unconfigured Text

A RefreshConfig built from defaultRefreshConfig was used without supplying the named effectful leaf (rcMint or rcClock). A wiring fault, not a runtime token condition.