Deterministic failure injection and a credential-free wiring mode, for a venue's Fake
to consult at the entry of its own public functions.
Why this exists
A consumer migrating fully onto the real per-venue Fake modules for testing (rather
than keeping a parallel hand-rolled test double indefinitely) needs two things none of
the four Fakes exposed on their own: a way to make a Fake fail on demand, to test a
consumer's own retry/circuit-breaker/alerting code without reaching a real venue; and a
way to skip a Fake's venue-faithful credential check, to test pure dispatch/decode
logic without constructing valid-looking credentials for every call.
Deterministic, never a rate
Every outcome here is queued explicitly and consumed in order. There is no
error_rate: 0.5-shaped knob and there will not be one: a test that fails 30% of the
time on its own schedule is not more useful than one that never fails, and "dialable"
was always asking for exact, reproducible control, not simulated flakiness.
Two independent axes
Which functions one override reaches is not configurable — every public function a
Fake gates through next_outcome/1 or next_outcome/2 is gated the same way. This
was a real design question (see docs/design/2026-09-04_webull-sharding-and-fake-injection.md
§3.6.1 in dp_exchange_core) and the answer is uniform gating: the feature this
replaces asked for exactly one knob, and building per-function targeting nobody asked
for is the same speculative surface this family's own conventions rule out on a bug fix.
Which symbol one override reaches, for a function that takes one, IS configurable —
a symbol-targeted override can never be satisfied by, or interfere with, a call for a
different symbol (§3.6.2). queue_failures/2 and fail_always/2 are whole-call;
queue_failures/3 and fail_always/3 target one symbol. A symbol-specific queue is
always checked before the whole-call one.
Built on Config, not a new mechanism
Every value here lives behind DpExchange.Core.Config.put_override/2 — process-scoped,
$callers-aware, safe under async: true, for the exact reason Config's own
moduledoc gives: a node-wide seam configured by one test configures it for every other
async test running beside it. This means injection only reaches a Fake function
called from the configuring test's own process (or a Task it spawned) — a Fake
called from inside a separately-supervised GenServer (as some streaming paths are)
will not see it, the same limitation every other Config-based seam in this family
already has.
Scope
Whole-call injection is exactly that: it picks the outcome one Fake function call
returns. It does not simulate a partially-failing batch — a function that takes a
list of symbols in one call (a venue's own bulk subscribe, say) and needs one symbol
within that batch to fail while the rest succeed needs the fake response itself to
carry partial success, which this does not attempt. Flagged as an open question in the
design doc, not resolved here.
Summary
Types
Whatever a Venue callback may return: a value to hand back as-is.
Functions
Makes venue's Fake skip its normal credential check for the rest of this process
(or a Task it spawns) — the default, venue-faithful refusal is untouched for any test
that does not call this.
Whether bypass_credentials/1 is in effect for venue in the calling process.
Every whole-call next_outcome/1 (and unmatched next_outcome/2) returns outcome until reset/1.
Every next_outcome/2 for symbol returns outcome until reset/1.
For a Fake function with no symbol argument.
For a Fake function whose call names symbol. Checks, in order: an always-fail set
for this exact symbol, a queued outcome for this exact symbol, an always-fail set for
every call, a queued whole-call outcome — the first of these that has something wins.
Queues outcomes to be returned, in order, by the next matching whole-call
next_outcome/1 reads (and next_outcome/2 reads for a symbol with no
symbol-specific queue of its own). Each call to next_outcome/1 or 2 pops one entry;
once exhausted, normal Fake behaviour resumes.
Queues outcomes for symbol only. A call naming a different symbol — or no symbol at
all — never sees these and is never affected by them.
Clears every override (queued failures, always-fail, credential bypass) for venue.
Types
@type outcome() :: term()
Whatever a Venue callback may return: a value to hand back as-is.
@type state() :: %{ global: [outcome()], global_always: outcome() | :none, by_symbol: %{optional(String.t()) => [outcome()]}, symbol_always: %{optional(String.t()) => outcome()}, bypass_credentials?: boolean() }
Injection state for one venue, held behind one Config override.
Functions
@spec bypass_credentials(atom()) :: :ok
Makes venue's Fake skip its normal credential check for the rest of this process
(or a Task it spawns) — the default, venue-faithful refusal is untouched for any test
that does not call this.
Whether bypass_credentials/1 is in effect for venue in the calling process.
Every whole-call next_outcome/1 (and unmatched next_outcome/2) returns outcome until reset/1.
Every next_outcome/2 for symbol returns outcome until reset/1.
For a Fake function with no symbol argument.
For a Fake function whose call names symbol. Checks, in order: an always-fail set
for this exact symbol, a queued outcome for this exact symbol, an always-fail set for
every call, a queued whole-call outcome — the first of these that has something wins.
Queues outcomes to be returned, in order, by the next matching whole-call
next_outcome/1 reads (and next_outcome/2 reads for a symbol with no
symbol-specific queue of its own). Each call to next_outcome/1 or 2 pops one entry;
once exhausted, normal Fake behaviour resumes.
Queues outcomes for symbol only. A call naming a different symbol — or no symbol at
all — never sees these and is never affected by them.
@spec reset(atom()) :: :ok
Clears every override (queued failures, always-fail, credential bypass) for venue.