API Conventions
The public API follows a few constructor and factory rules so a new primitive feels like the existing ones. Follow them when you add a pattern or component.
Patterns take a positional name
A pattern is the object user code calls directly, such as Lock,
CircuitBreaker, or a RateLimiter built from a factory. Its first argument is
a positional name that identifies the instance and drives its config prefix:
Lock("cart")
CircuitBreaker("payments")
RateLimiter.sliding_window("api", limit=100, window=60)
The name comes first because it is the one argument every call sets. Everything
else (backend=, tuning fields) is keyword-only with a default.
A pattern's name is what it protects
The name is the env prefix and the live-reconfiguration key, so it has to name the thing being tuned. A pattern that guards an external system takes the system's name and is shared between call sites. A pattern that shapes one call takes the call's name:
breaker = CircuitBreaker("recs-api") # shared by every recs call site
Stack("recs-list", patterns=[
Fallback("recs-list", when=Exception, default=[]),
breaker,
Timeout("recs-list", seconds=1.0),
])
CircuitBreaker and RateLimiter are system-scoped: one circuit and one quota
per dependency, however many call sites reach it. Retry, Timeout, and
Fallback are call-scoped, because what is safe to repeat, how long to wait,
and what to answer with all belong to the call. Bulkhead goes either way,
depending on whether it protects the dependency or your own workers.
Components take the provider first, name keyword-only
A component is app-level wiring passed to uses=, such as Coordination,
Cache, or RateLimiterComponent. Its first positional is the provider or
backend it wraps. The registration name is keyword-only and defaults to
"default":
Coordination(redis)
Cache(redis)
RateLimiterComponent(redis, name="api")
Most apps register one component per kind, so the default name keeps the common case silent. Name a second instance only when two of the same kind coexist.
Components take the bare capability name
A component is named after the capability it wires, not after its role:
Cache, Coordination, Log, Trace, Metrics, Outbox, HealthChecks.
Two carry the Component suffix, RateLimiterComponent and
CircuitBreakerComponent, because their bare names already belong to the
RateLimiter and CircuitBreaker patterns. The suffix names the concept from
Backends and Adapters, so it adds no vocabulary.
Providers build backends, components build patterns
A provider method returns the backend for a pattern and ends in _backend. A
component method returns the pattern:
backend = redis.lock_backend() # a LockBackend
lock = micro.coordination.lock("cart") # a Lock
Every provider names its factories the same way: lock_backend(),
readwritelock_backend(), leaderelection_backend(), schedule_backend(),
cache_backend(), outbox_backend(), ratelimiter_backend(), and
circuitbreaker_backend(). Coordination exposes the backends it holds under
the same names, such as coordination.readwritelock_backend.
Algorithms use factory classmethods
When a pattern has more than one algorithm, expose each as an explicit factory
classmethod rather than a kind= argument. The classmethod names the algorithm
and takes only the fields that algorithm needs:
RateLimiter.sliding_window("api", limit=100, window=60)
RateLimiter.token_bucket("api", capacity=20, refill_rate=10)
CircuitBreaker.consecutive_count("payments", error_threshold=5)
from_config is the one door for a pre-assembled config object (from YAML or a
pydantic-settings tree). The factory is the path most callers take.
The bare constructor is not a third door. CircuitBreaker("payments") works
because the consecutive-count algorithm is a sensible default. RateLimiter
has no default algorithm, so it has no bare constructor: both algorithms need
parameters the library cannot guess, which makes naming one part of building
the object.
from_config mirrors the constructor
from_config takes the constructor's positional arguments first, in order,
then config, then the same keyword-only arguments. It leaves out what the
config carries and the environment switches (env_load, env_prefix):
Lock("cart", backend=backend)
Lock.from_config("cart", config, backend=backend)
TTLCache(name="sessions")
TTLCache.from_config(config, name="sessions")
A class built only through factories, such as JWTVerifier, mirrors them:
JWTVerifier.from_config(config, fetch=fetch, name="partners"). A
constructor's *args follows config, as in
RateLimitedRequests.from_config(config, *limiters).
The OpenAPI schema has two words, for two things
include_in_schema= says whether a router grelmicro builds puts its own
routes in the schema. It is FastAPI's word for exactly that, so
health_router(include_in_schema=True) reads like the @app.get(...) a
reader already knows. It defaults to False: an orchestrator, a load
balancer and a scraper read the endpoint, never the schema.
openapi= says whether a component annotates routes you wrote.
IdempotentRequests(openapi=False) leaves your operations undescribed, and
adds no route of its own. It defaults to True, because a header the
middleware requires is part of your contract.
Two words because they are two operations. Adding a route to the schema and annotating someone else's are not the same act, and a component that serves no route has nothing to include.
A choice is a string literal, a result is an enum
An argument that picks a mode takes a Literal of strings. The caller
writes the string, with nothing to import:
from grelmicro.log import configure
from grelmicro.metrics import Metrics
Metrics(exporter="prometheus")
configure(backend="loguru", format="json")
grelmicro matches the string in any case and keeps the documented spelling,
whether it comes from an argument or an environment variable. So
GREL_LOG_LEVEL=info reads as INFO. The value reads back as a plain str.
Each set of choices has a type of its own, such as MetricsExporter or
LogFormat, for code that annotates a value.
A name a standard spells, such as a JWT algorithm like RS256, keeps its
case. GREL_ENVIRONMENT=Production and environment="Production" both read
as production.
A runtime state or outcome grelmicro returns for code to branch on stays an
enum, such as HealthStatus, CircuitBreakerState or FireOutcome. The
caller matches its members, and a type checker can tell when a branch is
missing.
A field of a describe() report is a Literal, such as CheckStatus or
TaskGate. The report is data to compare and print, so a test writes
report.gate == "claim" with nothing to import.
A stored duration is whole seconds or a timedelta
A duration that grelmicro stores or enforces takes an int of seconds or a
timedelta, never a float. A TTL, a lease, a quota window, a ban and a task
schedule are such durations. RateLimiter.sliding_window("api", limit=100,
window=60) is the common case, and window=timedelta(milliseconds=500)
covers a window under a second.
from datetime import timedelta
from grelmicro.resilience import RateLimiter
RateLimiter.sliding_window("api", limit=100, window=60)
RateLimiter.sliding_window("burst", limit=10, window=timedelta(milliseconds=500))
Both are whole microseconds from the start, so nothing has to guess what
was meant. A float such as 1.001 is 1000999.9999999999 microseconds.
From text, such as an environment variable, a duration reads whole seconds
("60") or an ISO 8601 duration in weeks, days, hours, minutes and seconds
("PT0.5S", "P1DT12H"). Only the seconds take a fraction, up to the
microsecond. Years and months are refused, since their length depends on the
calendar. A config dumped to JSON writes a duration the same way, in days and
smaller units ("P400D"), so it reads back exactly. A duration is at most
100 years.
A duration that says how long to keep something (cache_ttl,
keep_delivered) accepts zero, meaning keep nothing. No other duration
accepts zero, and None means no limit, never off.
Once validated, the config holds a timedelta, and its field is typed
timedelta. A component parameter is typed int | timedelta, so
RateLimiter.sliding_window("api", limit=100, window=60) type-checks. A
*Config built directly takes a timedelta in typed code, since a type
checker reads its parameters from the field types.
A wait, a timeout passed to I/O and the sleep between two runs of a
background loop stay a float of seconds, the type asyncio and HTTP clients
take.
| Parameter | Type | Moved |
|---|---|---|
SlidingWindowConfig.window |
int \| timedelta |
yes |
ClientBansConfig.window, .duration |
int \| timedelta |
yes |
ConsecutiveCountConfig.reset_timeout |
int \| timedelta |
yes |
LockConfig.lease_duration, ReadWriteLockConfig.lease_duration |
int \| timedelta |
yes |
TaskLockConfig.lease_duration, .min_hold_duration |
int \| timedelta |
yes |
LeaderElectionConfig.lease_duration, .renew_deadline |
int \| timedelta |
yes |
OutboxConfig.lease_duration |
int \| timedelta |
yes |
OutboxConfig.keep_delivered |
int \| timedelta \| None, 0 deletes on delivery, None keeps for good |
yes |
Outbox.purge(older_than) |
int \| timedelta, 0 purges every settled row |
yes |
TTLCacheConfig.ttl, Cache.ttl(ttl), cached(ttl, stale_ttl), TTLCache.set(ttl, stale_ttl), .get_or_set(ttl, stale_ttl), .set_many(ttl) |
int \| timedelta |
yes |
IdempotencyConfig.ttl, CachedResponsesConfig.ttl, .include per-path TTLs, CachedResponse(ttl) |
int \| timedelta |
yes |
DuplicateFilterConfig.ttl |
int \| timedelta \| None, None means no time limit |
yes |
HealthChecksConfig.cache_ttl |
int \| timedelta, 0 caches nothing |
yes |
JWKSConfig.ttl, .cache_ttl, DiscoveryConfig.ttl, .cache_ttl, JWTKeysConfig.cache_ttl |
int \| timedelta |
yes |
OAuthClientConfig.refresh_before, .default_lifetime |
int \| timedelta |
yes |
TaskRouter.every(seconds), renamed interval |
int \| timedelta |
yes |
cron misfire_grace_seconds, renamed misfire_grace |
int \| timedelta |
yes |
max_wait on the rate limiter, the bulkhead and idempotency |
float |
stays |
retry_interval, error_interval, poll_interval, export_interval |
float |
stays |
timeout, backend_timeout, request_timeout |
float |
stays |
shutdown_timeout, export_timeout, command_timeout |
float |
stays |
TimeoutConfig.seconds |
float |
stays |
Liveness.interval, .stall_timeout, .shutdown_timeout |
float, stall_timeout=None starts no watchdog |
stays |
RetryConfig.max_seconds, backoff delays, outbox retry_base and retry_max |
float |
stays |
Outbox.publish(delay), outbox Retry(delay) |
float \| timedelta |
stays |