Declarative Configuration
The User Guide Configuration page teaches the default path: build with keyword arguments, tune with environment variables. This page covers the other two construction paths and the resolution contract behind all three.
Every config-shaped grelmicro component takes its settings the same way. Pick the path that matches how your application is wired:
| Path | Call | When to use |
|---|---|---|
| Programmatic | Lock("cart", lease_duration=60) or RateLimiter.token_bucket("api", capacity=10, refill_rate=1) |
Scripts, notebooks, and code-first setups where all values are known inline. |
| Environmental | Lock("cart") |
Zero-boilerplate 12-factor deployments. Fields resolve from env, fall back to defaults. |
| Declarative | Lock.from_config("cart", cfg) or RateLimiter.from_config("api", cfg) |
Production where a settings tree is assembled at startup from YAML, Vault, or any central source. |
The three paths share one resolution rule: caller **kwargs win, then env, then
defaults. None kwargs are treated as unset and fall through to the next layer.
Programmatic
Pass values inline:
from grelmicro.coordination import Lock
lock = Lock("cart", lease_duration=60, retry_interval=0.1)
For variant-driven components (RateLimiter), use the factory classmethods:
from grelmicro.resilience import RateLimiter
api_limiter = RateLimiter.token_bucket("api", capacity=100, refill_rate=10)
auth_limiter = RateLimiter.sliding_window("auth", limit=5, window=60)
Environmental
Set env vars under the component's prefix and call the constructor with just the name:
export GREL_LOCK_CART_LEASE_DURATION=60
export GREL_LOCK_CART_RETRY_INTERVAL=0.1
lock = Lock("cart") # reads GREL_LOCK_CART_*
The instance name ("cart") becomes the namespace inside the prefix. Names with
hyphens, dots, slashes, or colons normalise into uppercase POSIX segments
(payments-eu becomes PAYMENTS_EU, cart.v2 becomes CART_V2).
The default instance drops the name segment, so a Lock("default") reads the
bare GREL_LOCK_*. The default instance owns the bare GREL_{COMPONENT}_
namespace, so name your other instances to avoid clashing with a field name (a
Lock("lease") would share GREL_LOCK_LEASE_DURATION with the default
instance). This is rare in practice.
Prefix reference
| Component | Prefix |
|---|---|
Lock("default") |
GREL_LOCK_ |
Lock("cart") |
GREL_LOCK_CART_ |
TaskLock("etl") |
GREL_TASKLOCK_ETL_ |
LeaderElection("svc") |
GREL_LEADERELECTION_SVC_ |
RateLimitFilter() |
GREL_RATELIMITFILTER_ |
RateLimitFilter(env_name="audit") |
GREL_RATELIMITFILTER_AUDIT_ |
DuplicateFilter() |
GREL_DUPLICATEFILTER_ |
DuplicateFilter(env_name="audit") |
GREL_DUPLICATEFILTER_AUDIT_ |
HealthChecks() |
GREL_HEALTH_ |
log.configure() |
GREL_LOG_ |
Declarative
Build a config object, then construct via from_config:
from grelmicro.coordination import Lock
from grelmicro.coordination.lock import LockConfig
config = LockConfig(
worker="web-1",
lease_duration=60,
retry_interval=0.1,
)
lock = Lock.from_config("cart", config)
async def main():
async with lock:
print("Protected resource accessed")
The config object is a frozen Pydantic model. Field names match the kwargs from
the programmatic path. from_config skips the env layer entirely.
Every primitive takes the same path, and from_config is the only door for a
pre-built config. It reads as what it does: the config you hand over is the
whole truth, so the env layer is skipped and the instance is not registered for
live reload. The kwargs on the constructor are the other lane, where env fills
whatever you left out.
import httpx
from grelmicro.resilience import (
ExponentialBackoff,
Match,
Retry,
RetryConfig,
)
config = RetryConfig(
attempts=5,
when=Match.exception(httpx.HTTPError),
backoff=ExponentialBackoff(base_delay=0.2, max_delay=10.0, jitter="full"),
)
policy = Retry.from_config("payments", config)
from grelmicro.resilience import Timeout, TimeoutConfig
config = TimeoutConfig(seconds=2.0)
db_timeout = Timeout.from_config("db", config)
import httpx
from grelmicro.resilience import ApiShieldConfig, Shield
config = ApiShieldConfig(
timeout_errors=(httpx.TimeoutException, httpx.ConnectError),
max_rate=20.0,
)
github = Shield.from_config("github", config)
import httpx
from grelmicro.resilience import Fallback, FallbackConfig, Match
config = FallbackConfig(
when=Match.exception(httpx.HTTPError),
default=[],
)
policy = Fallback.from_config("recs", config)
from grelmicro.resilience import CircuitBreaker, ConsecutiveCountConfig
config = ConsecutiveCountConfig(
error_threshold=10,
reset_timeout=60.0,
ignore_exceptions=(ValueError,),
)
cb = CircuitBreaker.from_config("payments", config)
from grelmicro.resilience import RateLimiter, SlidingWindowConfig
cfg = SlidingWindowConfig(limit=5, window=60)
limiter = RateLimiter.from_config("auth", cfg)
Resolution order
When __init__ runs, the final value of each field is picked from the first
source that has it:
- Caller
**kwargs. - Env var matching the component prefix (when
env_load=True, or whenenv_loadis unset andGREL_ENV_LOADis truthy). Configclass default.
The hazard in step 2
Step 2 fills every field the caller did not pass, not only the fields that have no default anywhere. Passing some fields and leaving others therefore splits the config across two sources.
That is the trap for config held in your own Settings object. The fields you
hand over win, and the rest come from the environment. A Settings default that
differs from the environment is dropped, and nothing says so.
class AppSettings(BaseSettings):
lease: float = 30.0 # your default
settings = AppSettings()
lock = Lock("cart", retry_interval=0.5) # lease_duration not passed
With GREL_LOCK_CART_LEASE_DURATION=99 in the environment, that lock leases for
99 seconds. Not 30, and not the library default either. The failure is silent
and only shows up when the two sources disagree, which is the case nobody
tests.
Recipes
Custom env prefix
lock = Lock("cart", env_prefix="MYAPP_LOCK_CART_")
Disable env reads
lock = Lock("cart", env_load=False, lease_duration=10)
env_load=False says the values passed here are the whole truth. Every field
not passed falls back to the Config default, and step 2 is skipped, so the
environment cannot fill the gaps.
Reach for it whenever the values come from somewhere that is already the source
of truth. from_config says the same thing positively and reads better when the
config already exists as an object.
Wire from pydantic-settings
Centralise everything under one BaseSettings and hand grelmicro the slices it
needs:
from pydantic_settings import BaseSettings
from grelmicro import Grelmicro
from grelmicro.cache import Cache
from grelmicro.providers.redis import RedisProvider
from grelmicro.coordination import Lock
from grelmicro.coordination.lock import LockConfig
class AppSettings(BaseSettings):
cart_lock: LockConfig = LockConfig()
redis_url: str = "redis://localhost:6379/0"
settings = AppSettings()
cart_lock = Lock.from_config("cart", settings.cart_lock)
redis = RedisProvider(settings.redis_url)
micro = Grelmicro(uses=[Cache(redis)])
Going deeper
The Configuration architecture page covers
resolve_config(), hot-path discipline, and where the Config classes live.