# `DpExchange.Core.Config`
[🔗](https://github.com/DistortionPoint/dp-exchange-core/blob/v0.3.120/lib/dp_exchange/core/config.ex#L1)

Resolves a configurable seam, per process rather than per node.

**Every seam in this family that a consumer's tests may need to vary goes through
here** — which fake is in play, what that fake returns, which rate limiter is active,
anything added later. There is one resolver so that five venue packages cannot each
invent a variant of it.

## Why this is not `Application.get_env/3`

`Application.put_env/3` is node-wide. A consumer running `async: true` — which is
every well-built Elixir suite — has many tests on one node at once, so a test that
configures a seam globally configures it for **every other test running beside it**.

That is not hypothetical. In the application these packages were extracted from, seven
tests set a global rate-limiting flag for their duration, and for that duration every
other async test on the node was suddenly metered against a one-request bucket. An
unrelated WebSocket test came back `{:error, {:rate_limited, 1}}` while asserting on
connection errors. The failure was silent, intermittent and seed-dependent — the worst
combination, and it cost a day to find.

## Lookup order

1. **`Process.get/1` in the calling process** — the override a test set for itself.
2. **The `$callers` ancestor chain.** ExUnit propagates `:"$callers"` to spawned
   `Task`s, so a task launched inside a test finds the override the test set without
   the test threading it through every closure. **This is the step that gets omitted**,
   and omitting it makes the seam work in simple tests and fail in exactly the
   concurrent ones it exists for.
3. **`Application.get_env/3`** — the global default. This is what production reads and
   what a consumer configures normally; the process-scoped steps are a test affordance
   layered above it, not a replacement.

## Crossing a process boundary

A `GenServer` runs in its own process and will not find the caller's dictionary at all,
because it is not in the caller's `$callers` chain. **Resolve in the caller and put the
answer in the message** — `snapshot/1` on the way in, `resolve_snapshot/4` on the way
out. Resolving inside the server is too late, and it fails in the direction that looks
like it works: production is unaffected, so only the consumer's async suite breaks.

## Examples

    # Production: reads application env, as normal.
    Config.get(:dp_exchange_core, :rate_limit_module, DefaultRateLimiter)

    # A test, for its own process tree only:
    Config.put_override(:rate_limit_module, AlwaysRateLimited)

## `opt/3` — the same problem, one layer down

This module resolves *seams* — where consumer configuration meets Core. `opt/3` fixes a
related but distinct trap that lives in `keyword()` options passed on individual calls,
not in application config: **`Keyword.get/3` only substitutes its default for an ABSENT
key, never for a key that is present and `nil`.**

That distinction is invisible reading the code and is reachable on every call, because
every venue package in this family forwards its own `opts` **unchanged**, by convention,
through several layers — a `Feed` passes its `opts` to `PollingFeed.start_link/1`, which
never itself set `interval_ms`. When the venue's own caller never configured a key
either, it does not vanish; it arrives as `key: nil`, explicit and present, because
something upstream did `Keyword.get(callers_opts, :interval_ms)` with no default and
handed the `nil` straight through.

`Keyword.get(opts, :interval_ms, 30_000)` against `interval_ms: nil` returns `nil`, not
`30_000` — the default silently does not apply. This was fixed once, by hand, at exactly
one call site (`PollingFeed`'s `:start_delay_ms`, with an incident recorded in its own
comment) and left open everywhere else that mattered: `PollingFeed`'s `:interval_ms`
crashed `Process.send_after/3` outright; `HttpClient`'s `:retry_attempts` was worse — a
`nil` compares as **greater** than any integer in Erlang term ordering, so it silently
entered the retry branch and then died computing `4 - nil`, killing the *calling* venue
process, which this library does not supervise.

`opt/3` is `Keyword.get/3` with that one difference: a present-and-`nil` value is treated
the same as an absent one. It does **not** use `||`, because `||` is falsy on `false` too
— an explicit `log_requests: false` or `raw_status: false` must survive, and a helper that
silently overrode a real `false` back to its default would be the same bug in the other
direction.

# `app`

```elixir
@type app() :: atom()
```

The application key a seam's global default lives under.

# `key`

```elixir
@type key() :: atom()
```

The name of the seam.

# `delete_override`

```elixir
@spec delete_override(key()) :: :ok
```

Removes a process-scoped override set by `put_override/2`.

Only clears the calling process's own override; an ancestor's is untouched, because
a child must not be able to reconfigure the test that spawned it.

# `find_override`

```elixir
@spec find_override(key()) :: {:ok, term()} | :none
```

Finds the process-scoped override for `key` without falling back to application env.

Returns `{:ok, value}` or `:none`. The two are distinguished deliberately: an override
whose value is `nil` is an override, and collapsing it into "not set" would make `nil`
unconfigurable.

# `get`

```elixir
@spec get(app(), key(), term()) :: term()
```

Resolves `key`, preferring a process-scoped override and falling back to `app`'s
application environment.

## Examples

    iex> DpExchange.Core.Config.get(:dp_exchange_core, :nothing_configured, :fallback)
    :fallback

    iex> DpExchange.Core.Config.put_override(:some_seam, :overridden)
    iex> DpExchange.Core.Config.get(:dp_exchange_core, :some_seam, :fallback)
    :overridden

# `opt`

```elixir
@spec opt(keyword(), atom(), term()) :: term()
```

Reads `key` from a `keyword()` options list, treating an explicit `nil` value the same
as an absent key.

Unlike `Keyword.get/3`, `opt(opts, :interval_ms, 30_000)` returns `30_000` whether
`:interval_ms` is missing from `opts` OR present as `interval_ms: nil` — the shape a
venue's forwarded `opts` produces when nothing upstream ever set it. See this module's
`opt/3` section for why that distinction matters and why this does not use `||`.

## Examples

    iex> DpExchange.Core.Config.opt([], :interval_ms, 30_000)
    30_000

    iex> DpExchange.Core.Config.opt([interval_ms: nil], :interval_ms, 30_000)
    30_000

    iex> DpExchange.Core.Config.opt([interval_ms: 5_000], :interval_ms, 30_000)
    5_000

    iex> DpExchange.Core.Config.opt([log_requests: false], :log_requests, true)
    false

# `put_override`

```elixir
@spec put_override(key(), term()) :: :ok
```

Sets a process-scoped override for `key`, visible to this process and anything it
spawns that carries `$callers`.

Scoped to the calling process, so it never reaches a test running beside this one.

# `resolve_snapshot`

```elixir
@spec resolve_snapshot(%{optional(key()) =&gt; term()}, app(), key(), term()) :: term()
```

Resolves `key` inside a process that received a `snapshot/1`.

The snapshot wins; otherwise this falls back to `app`'s application env, exactly as
`get/3` does — **`app` is a required argument for that reason**: this used to hardcode
`:dp_exchange_core` regardless of what a caller passed, so a venue package snapshotting
one of ITS OWN seams (as `dp_exchange_schwab`'s poller does with
`DpExchange.Core.Config.snapshot/1`)
and resolving it inside its own GenServer would have had this function consult Core's
application env instead of its own — silently never finding a value the venue's own
consumer configured, no matter how it was set. Found with no live caller yet: every
known consumer reapplies a snapshot with `put_override/2` in a loop rather than calling
this, which is why the mismatch between "falls back exactly as `get/3` does" and a
hardcoded app went unnoticed. Fixed before a first caller could inherit it.

This deliberately does **not** consult the server's own process dictionary: a
long-lived server's dictionary is not scoped to any one caller, so honouring it would
leak one caller's configuration into another's request.

## Examples

    iex> DpExchange.Core.Config.resolve_snapshot(%{seam: :from_caller}, :dp_exchange_core, :seam, :fallback)
    :from_caller

    iex> DpExchange.Core.Config.resolve_snapshot(%{}, :dp_exchange_core, :never_set_anywhere, :fallback)
    :fallback

# `snapshot`

```elixir
@spec snapshot([key()]) :: %{optional(key()) =&gt; term()}
```

Captures the caller's overrides for `keys` so they can be carried across a process
boundary in a message.

Call this in the process that has the override — the caller — and pass the result into
the `GenServer.call/3` or `cast/2`. The server then resolves with
`resolve_snapshot/4`.

## Examples

    iex> DpExchange.Core.Config.put_override(:carried, 42)
    iex> DpExchange.Core.Config.snapshot([:carried, :absent])
    %{carried: 42}

---

*Consult [api-reference.md](api-reference.md) for complete listing*
