Skip to content

Providers

A Provider is a first-class connection object. It owns the vendor URL, the native client (a Redis pool, an asyncpg pool, ...), and the lifecycle of both. Components like Coordination, Cache, and RateLimiterComponent accept a Provider directly and use its matching adapter under the hood, and a Provider listed on its own registers one of each for you.

Five providers ship today: RedisProvider, ValkeyProvider, PostgresProvider, SQLiteProvider, and MemoryProvider. More will follow.

List the Provider and nothing else:

from grelmicro import Grelmicro
from grelmicro.providers.redis import RedisProvider

redis = RedisProvider("redis://localhost:6379/0")

micro = Grelmicro(uses=[redis])

async with micro:
    ...

That registers Coordination, Cache, RateLimiterComponent, and CircuitBreakerComponent, all sharing the one pool. Each Component dispatches to the Provider's factory methods (provider.lock(), provider.cache(), provider.ratelimiter()). The Adapter classes (RedisLockAdapter, RedisCacheAdapter, RedisRateLimiterAdapter) stay public as escape hatches but rarely appear in user code.

Import policy: prefer Providers over concrete adapters

App code should import a Provider and pass it to Components, not import concrete adapter classes. The Provider owns the connection and hands each Component the right adapter, so one URL change swaps every backend at once. Import an adapter directly only for an escape hatch: a bespoke client the factory does not build, or a per-process Memory backend in a test. Adapters live in their backend submodule (grelmicro.resilience.circuitbreaker.sqlite) and the top-level package re-exports them (from grelmicro.resilience import SQLiteCircuitBreakerAdapter).

Name a Component to override one kind

A Component claims its own kind and the Provider fills the rest, so one entry moves one capability:

micro = Grelmicro(uses=[redis, Cache(postgres)])

Everything stays on Redis except the cache. A bare backend works the same way and is wrapped in its Component for you:

micro = Grelmicro(uses=[redis, MemoryCircuitBreakerAdapter()])

A Provider held by a Component is discovered and lifecycled for you, so you can drop the top-level entry once every kind is spelled out. The shared redis opens once, before the Components that hold it. Listing it explicitly lets you control where it sits in the lifecycle order.

Recipe 1: env-driven

Construct the Provider without arguments and let it read REDIS_* from the environment:

from grelmicro import Grelmicro
from grelmicro.providers.redis import RedisProvider

redis = RedisProvider()  # reads REDIS_URL or REDIS_HOST + REDIS_PORT + ...

micro = Grelmicro(uses=[redis])

Set REDIS_URL (or REDIS_HOST + REDIS_PORT + REDIS_DB + REDIS_PASSWORD) in the environment.

These reads need no flag. GREL_ENV_LOAD gates the GREL_* variables that tune components, not a Provider's own connection variables. Pass env_load=False to build a Provider from keyword arguments alone.

Recipe 2: split pools by env prefix

Two Redis instances (or two databases) live behind different prefixes. Each prefix gets its own Provider:

cache_redis = RedisProvider(env_prefix="CACHE_REDIS_")
session_redis = RedisProvider(env_prefix="SESSION_REDIS_")

micro = Grelmicro(uses=[
    cache_redis,
    session_redis,
    Coordination(session_redis),
    Cache(cache_redis),
])

Set CACHE_REDIS_URL and SESSION_REDIS_URL (or the decomposed forms). The two components talk to two pools.

Recipe 3: bring your own client

You already own a Redis client (custom retry, sentinel, auth, or a testcontainers fixture). Wrap it with from_client:

import redis.asyncio as redis

from grelmicro import Grelmicro
from grelmicro.cache import Cache
from grelmicro.providers.redis import RedisProvider

client = redis.Redis(host="prod.cache", socket_timeout=5)
redis_provider = RedisProvider.from_client(client)  # caller owns the client

micro = Grelmicro(uses=[Cache(redis_provider)])

Pass own=True to hand ownership to the provider. It will close the client when the provider exits, useful in pytest fixtures:

@pytest.fixture
async def redis_provider(redis_container):
    async with RedisProvider.from_client(
        redis_container.get_client(), own=True
    ) as provider:
        yield provider

Recipe 4: the managed connection, nothing else

Not all of your data is a cache, a lock, or a rate limiter. Plain application state in a Redis hash is still yours to read and write, and a Provider gives you that connection with the lifecycle already handled. Reach for provider.client:

from pydantic import BaseModel

from grelmicro import Grelmicro
from grelmicro.providers.redis import RedisProvider

redis = RedisProvider("redis://localhost:6379/0")

micro = Grelmicro(uses=[redis])


class Order(BaseModel):
    total: str


async def save_order(order_id: str, total: str) -> None:
    await redis.client.hset(f"order:{order_id}", mapping={"total": total})


async def load_order(order_id: str) -> Order:
    return Order.model_validate(await redis.client.hgetall(f"order:{order_id}"))

The client is the native one (redis.asyncio.Redis, asyncpg.Pool, aiosqlite.Connection), so every command that library offers is available. The app opens it on startup and closes it on shutdown, and a HealthChecks can probe it with add_provider(redis).

Two things to know. The client exists only inside the app scope, so a call made before startup raises OutOfContextError. And a lone Provider listed with no components also registers a default component per kind it serves, which costs nothing if you never use them. List the components you do want to be explicit about the wiring.

Construction forms

Every Provider takes the same shapes. Redis as the example:

RedisProvider("redis://localhost:6379")      # positional URL
RedisProvider(url="redis://...")             # keyword URL
RedisProvider(host="x", port=6379, db=0)     # decomposed kwargs
RedisProvider()                              # env-driven (REDIS_*)
RedisProvider(env_prefix="CACHE_REDIS_")     # custom env prefix
RedisProvider(env_load=False)                # kwargs only, no env
RedisProvider.from_config(RedisConfig(...))  # from a config object
RedisProvider.from_client(client)            # bring-your-own client

URL validation

A URL reaches a provider from three places: the constructor, the environment, and a config object. All three check it against the same type, so a URL accepted in one is accepted in the others, and a URL refused in one is refused in all of them.

A URL the provider cannot use is refused before any client is built:

RedisProvider("anything://localhost:6379")
# SettingsValidationError: Could not validate settings:
# - url: URL scheme should be 'redis', 'rediss', 'unix', 'redis+sentinel' or 'redis+cluster'

Every provider raises SettingsValidationError, the same error every other class raises for a bad value, so one except covers every way the URL arrives.

Credentials in a config object

A connection URL carries its password inside itself, so a config object holding one would print it. The url field on PostgresConfig and RedisConfig is a SecretUrl: it displays the URL with every credential replaced by ***, and hands back the real value only through get_secret_value().

from grelmicro.providers.redis import RedisConfig

config = RedisConfig(url="redis://app:hunter2@cache:6379/0")

print(repr(config))
# RedisConfig(url=SecretUrl('redis://app:***@cache:6379/0'), host=None, ...)

print(config.url.get_secret_value())
# redis://app:hunter2@cache:6379/0

Building the config is unchanged: pass a plain string and grelmicro wraps it. The scheme, host, port, and database stay readable, so a log line still tells an operator which server the app is talking to.

The same masking covers repr(), model_dump(), and model_dump_json(). Passing the config to from_config() uses the real URL, and so does every connection grelmicro opens.

A rejected value is never quoted back either. A mistyped URL would carry its password into the ValidationError text, so these configs report the failing field without the input.

Do not persist a config through JSON

model_dump_json() writes the masked form. Reloading that output gives you a config whose password is the literal ***, and the connection then fails to authenticate. Persist the value from get_secret_value() instead, or keep the credential in the environment and let the provider read it. This matches how SecretStr already behaves for the password field.

Factory methods

Each Provider exposes factory methods that return its matching adapter:

Method Returns RedisProvider ValkeyProvider PostgresProvider SQLiteProvider MemoryProvider
.lock(**kwargs) LockBackend implementation
.schedule(**kwargs) ScheduleBackend impl
.leaderelection(**kwargs) LeaderElectionBackend impl N/A
.cache(**kwargs) CacheBackend implementation
.ratelimiter(**kwargs) RateLimiterBackend impl
.circuitbreaker(**kwargs) CircuitBreakerBackend impl

Factories that do not apply raise NotImplementedError with a message pointing to the right alternative. Coordination(provider), Cache(provider), RateLimiterComponent(provider), and CircuitBreakerComponent(provider) call these factories.

Readiness check

Every connection provider ships a built-in check() readiness probe: Redis and Valkey run PING, Postgres and SQLite run SELECT 1, and Memory returns ready right away. A HealthChecks registers it as a provider:{short_name} check, one provider at a time with health.add_provider(provider) or for the whole app with HealthChecks(auto_health=True). See Health Checks.

Lifecycle

The Provider is opened when the Grelmicro app enters and closed when the app exits. Components borrow the Provider's client without managing its lifecycle.

Order does not matter. A Provider opens before the Components that borrow it, wherever you list it, and a Provider you leave out entirely is discovered and opened for you. uses= says what the app is made of, and grelmicro opens it in dependency order.

This matters because the resource is often lazy: PostgresProvider builds its asyncpg.Pool on __aenter__, so a Component that opened first would reach for provider.client before the pool exists.

Listing the Provider first still reads well and is worth doing for a human reader. If you want the list you wrote to be exactly the list that runs, Grelmicro(strict=True) raises LifecycleOrderError instead of reordering.

SQLite

SQLiteProvider ships the .lock(), .ratelimiter(), .cache(), .circuitbreaker(), and .schedule() factories. The provider owns one aiosqlite connection (autocommit, WAL) and a shared lock that adapters borrow.

from grelmicro import Grelmicro
from grelmicro.providers.sqlite import SQLiteProvider

sqlite = SQLiteProvider("app.db")

micro = Grelmicro(uses=[sqlite])

Set SQLITE_PATH for env-driven construction. Construction forms:

SQLiteProvider("app.db")                  # positional path
SQLiteProvider(path="app.db")             # keyword path
SQLiteProvider()                          # env-driven (SQLITE_PATH)
SQLiteProvider(env_prefix="CACHE_SQLITE_")  # custom env prefix
SQLiteProvider(env_load=False)            # kwargs only, no env
SQLiteProvider.from_config(SQLiteConfig(...))
SQLiteProvider.from_client(connection)    # bring-your-own connection

Memory

MemoryProvider ships every factory: .lock(), .leaderelection(), .schedule(), .cache(), .ratelimiter(), and .circuitbreaker(). It owns no connection. State lives in process and disappears on restart, so it is for tests and single-process apps. Reach for Redis, Postgres, or SQLite for durable, distributed coordination.

from grelmicro import Grelmicro
from grelmicro.providers.memory import MemoryProvider

memory = MemoryProvider()

micro = Grelmicro(uses=[memory])

Each factory hands back one cached adapter per kind, so the provider owns a single in-process store per kind. memory.lock() called twice returns the same backend, so a later call re-fetches the live store for a test or an introspection. Reach for a factory when a test needs the live store, or when one kind should run on a different backend than the rest.

To wire a single component, pass the provider straight in:

from grelmicro import Grelmicro
from grelmicro.coordination import Coordination
from grelmicro.providers.memory import MemoryProvider

memory = MemoryProvider()

micro = Grelmicro(uses=[
    Coordination(memory),
])

You can still pass a raw adapter (MemoryLockAdapter, MemoryCacheAdapter, ...) to its Component when you do not want a provider.