Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/services/fxQuorumEvaluator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* - config validation (k, tolerance, reference)
* - allowReducedQuorum flag semantics
* - metric emissions (evaluated / passed / failed / in-consensus / divergence)
* - METRIC_QUORUM_EVALUATED failure-path regression (config validation + empty result)
* - pager invoked with divergent rates; pager exceptions never crash caller
* - FxQuorumAlerting: pages ops and writes audit event (incl. divergent rates)
* - assess() is non-throwing; evaluate() throws FxQuorumFailedError
Expand All @@ -29,6 +30,7 @@ import {
FxQuorumFailedError,
FxQuorumAlerting,
METRIC_QUORUM_EVALUATED,
METRIC_QUORUM_EVALUATED,
METRIC_QUORUM_PASSED,
METRIC_QUORUM_FAILED,
METRIC_QUORUM_IN_CONSENSUS,
Expand Down Expand Up @@ -105,6 +107,99 @@ describe('FxQuorumEvaluator construction', () => {
});
});

// ─── METRIC_QUORUM_EVALUATED failure handling (regression) ────────────────────

describe('FxQuorumEvaluator: METRIC_QUORUM_EVALUATED failure handling', () => {
it('does not emit METRIC_QUORUM_EVALUATED when construction fails on invalid k', () => {
const metrics = makeMetrics();
expect(() => new FxQuorumEvaluator({ k: 0, tolerance: 0.01 }, { metrics })).toThrow(/k must be an integer/);
const prom = metrics.exportPrometheus();
expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBe(0);
});

it('does not emit METRIC_QUORUM_EVALUATED when construction fails on invalid tolerance', () => {
const metrics = makeMetrics();
expect(() => new FxQuorumEvaluator({ k: 2, tolerance: -1 }, { metrics })).toThrow(/tolerance/);
const prom = metrics.exportPrometheus();
expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBe(0);
});

it('does not emit METRIC_QUORUM_EVALUATED when construction fails on invalid reference', () => {
const metrics = makeMetrics();
expect(() => new FxQuorumEvaluator({ k: 2, tolerance: 0.01, reference: 'bogus' as any }, { metrics })).toThrow(/reference/);
const prom = metrics.exportPrometheus();
expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBe(0);
});

it('emits METRIC_QUORUM_EVALUATED on the empty-result (total outage) failure path', () => {
const metrics = makeMetrics();
const e = new FxQuorumEvaluator({ k: 2, tolerance: 0.005 }, { metrics });
expect(() => e.evaluate('USD/EUR', [
{ providerId: 'a', rate: null },
{ providerId: 'b', rate: null },
])).toThrow(FxQuorumFailedError);
const prom = metrics.exportPrometheus();
// The evaluation was attempted, so the evaluated counter must be observable
// even though the run failed and no consensus rate was produced.
expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBeGreaterThanOrEqual(1);
expect(countMetric(prom, METRIC_QUORUM_FAILED)).toBeGreaterThanOrEqual(1);
expect(countMetric(prom, METRIC_QUORUM_PASSED)).toBe(0);
});

it('emits METRIC_QUORUM_EVALUATED on the divergence failure path', () => {
const metrics = makeMetrics();
const e = new FxQuorumEvaluator({ k: 2, tolerance: 0.005 }, { metrics });
expect(() => e.evaluate('USD/EUR', [
{ providerId: 'a', rate: makeRate('1.0000') },
{ providerId: 'b', rate: makeRate('1.2000') },
])).toThrow(FxQuorumFailedError);
const prom = metrics.exportPrometheus();
expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBeGreaterThanOrEqual(1);
expect(countMetric(prom, METRIC_QUORUM_FAILED)).toBeGreaterThanOrEqual(1);
});

it('emits METRIC_QUORUM_EVALUATED exactly once per successful evaluate() call', () => {
const metrics = makeMetrics();
const e = new FxQuorumEvaluator({ k: 2, tolerance: 0.005 }, { metrics });
e.evaluate('USD/EUR', [
{ providerId: 'a', rate: makeRate('1.0000') },
{ providerId: 'b', rate: makeRate('1.0001') },
]);
const prom = metrics.exportPrometheus();
expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBe(1);
expect(countMetric(prom, METRIC_QUORUM_PASSED)).toBe(1);
expect(countMetric(prom, METRIC_QUORUM_FAILED)).toBe(0);
});

it('emits METRIC_QUORUM_EVALUATED exactly once per failed evaluate() call', () => {
const metrics = makeMetrics();
const e = new FxQuorumEvaluator({ k: 2, tolerance: 0.005 }, { metrics });
expect(() => e.evaluate('USD/EUR', [
{ providerId: 'a', rate: makeRate('1.0000') },
{ providerId: 'b', rate: makeRate('1.2000') },
])).toThrow(FxQuorumFailedError);
const prom = metrics.exportPrometheus();
expect(countMetric(prom, METRIC_QUORUM_EVALUATED)).toBe(1);
expect(countMetric(prom, METRIC_QUORUM_FAILED)).toBe(1);
expect(countMetric(prom, METRIC_QUORUM_PASSED)).toBe(0);
});

it('assess() on the empty-result path reports agreed=false without emitting a passed counter', () => {
const metrics = makeMetrics();
const e = new FxQuorumEvaluator({ k: 2, tolerance: 0.005 }, { metrics });
const r = e.assess('USD/EUR', [
{ providerId: 'a', rate: null },
{ providerId: 'b', rate: null },
]);
expect(r.agreed).toBe(false);
expect(r.valid).toBe(0);
expect(r.inConsensus).toBe(0);
expect(r.total).toBe(2);
const prom = metrics.exportPrometheus();
expect(countMetric(prom, METRIC_QUORUM_PASSED)).toBe(0);
});
});

// ─── Quorum satisfied ─────────────────────────────────────────────────────────

describe('FxQuorumEvaluator: quorum satisfied', () => {
Expand Down
25 changes: 25 additions & 0 deletions src/services/fxQuorumEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@
* @module services/fxQuorumEvaluator
*/

// Regression coverage for METRIC_QUORUM_EVALUATED failure handling lives in
// `src/services/__tests__/fxQuorumEvaluator.test.ts`. The constructor guards
// below are part of the public contract and are asserted there.

import { Decimal } from '../lib/decimal';
import { AppError, ErrorCode } from '../lib/errors';
import { Logger } from '../lib/logger';
Expand Down Expand Up @@ -236,26 +240,35 @@ export class FxQuorumEvaluator {
} = {},
) {
if (!Number.isInteger(config.k) || config.k < 1) {
// Contract: k must be an integer >= 1. Thrown before any state is set so
// a misconfigured evaluator can never be constructed.
throw new Error('FxQuorumEvaluator: k must be an integer >= 1');
}
if (
typeof config.tolerance !== 'number' ||
!Number.isFinite(config.tolerance) ||
config.tolerance < 0
) {
// Contract: tolerance must be a finite number >= 0. NaN/Infinity and
// negative values are rejected deterministically.
throw new Error('FxQuorumEvaluator: tolerance must be a finite number >= 0');
}
if (
config.reference !== undefined &&
config.reference !== 'median' &&
config.reference !== 'mean'
) {
// Contract: reference must be 'median' or 'mean' when provided. Any other
// value (including null/undefined-like strings) is rejected.
throw new Error("FxQuorumEvaluator: reference must be 'median' or 'mean'");
}

this.k = config.k;
this.tolerance = config.tolerance;
this.reference = config.reference ?? 'median';
// minValidProviders defaults to k; allowReducedQuorum defaults to true.
// Both are surfaced via getConfig() so tests can assert the resolved
// defaults without reaching into private state.
this.minValidProviders = config.minValidProviders ?? config.k;
this.allowReducedQuorum = config.allowReducedQuorum ?? true;
}
Expand Down Expand Up @@ -374,6 +387,9 @@ export class FxQuorumEvaluator {
const result = this.assess(pair, inputs);
const { metrics, logger, pager } = this.options;

// METRIC_QUORUM_EVALUATED is emitted exactly once per evaluate() call,
// before either the success or failure branch, so the counter is
// observable and deterministic regardless of outcome.
metrics?.incrementCounter(METRIC_QUORUM_EVALUATED, { pair: sanitizePair(pair) });

if (result.agreed && result.consensusRate) {
Expand All @@ -384,6 +400,9 @@ export class FxQuorumEvaluator {
return result.consensusRate;
}

// ── Failure path: emit, log, page, then block the run. ──
// The failure branch must always increment METRIC_QUORUM_FAILED, set the
// in-consensus and divergence gauges, log, page (best-effort), and throw.
// ── Failure path: emit, log, page, then block the run. ──
metrics?.incrementCounter(METRIC_QUORUM_FAILED, { pair: result.pair });
metrics?.setGauge(METRIC_QUORUM_IN_CONSENSUS, result.inConsensus, {
Expand All @@ -406,6 +425,8 @@ export class FxQuorumEvaluator {
divergent: result.divergent,
});

// Pager is best-effort: a throwing or rejecting pager must never mask the
// FxQuorumFailedError that the pipeline relies on to block the run.
if (pager) {
try {
const paged = pager(result);
Expand All @@ -420,12 +441,16 @@ export class FxQuorumEvaluator {
}
}

// Always throw the operational 503 after emitting metrics/logs/pages so
// callers observe a deterministic error contract.
throw new FxQuorumFailedError(result);
}

/** Exposed for tests / review: the parsed, validated config. */
getConfig(): Readonly<FxQuorumConfig> {
return {
// Return the resolved values (defaults applied) so tests can assert the
// effective configuration rather than the raw input.
k: this.k,
tolerance: this.tolerance,
reference: this.reference,
Expand Down
Loading