Changelog
0.44.0 - 2026-10-27
Breaking
- 💥 A mode field such as
MetricsConfig.exporterreads back as a plainstr, not as its old enum member..valueand.nameraiseAttributeError. Compare it with the string. (#1175) - 💥
MetricsExporterType,TraceExporterType,TraceProcessorType,TraceSamplerType,LogLevelType,LogFormatType,LogBackendTypeandLogSerializerTypeare removed. Pass the string instead. (#1175) - 💥
GREL_ENVIRONMENTmatches the tier in any case, soGREL_ENVIRONMENT=Productiondeclaresproductionwith no warning. (#1176) - 💥
HealthChecks()with noprovider_checks=defaults to"report". Each Provider on the app shows as a non-criticalprovider:{short_name}check in/healthz, and/readyzstays up while it is down. Passprovider_checks="off"to register none. (#1180) - 💥
HealthChecks(auto_health=)andHealthChecks.from_config(auto_health=)are removed and raiseTypeError. Passprovider_checks="require"orprovider_checks="off". (#1180) - 💥 A
GREL_*variable is read by default. A variable set whileGREL_ENV_LOADis unset now applies, with no warning. SetGREL_ENV_LOAD=0or passenv_load=Falseto keep it unread. (#1183)
Added
- ✨
micro.describe()names the bare Provider each default component came from inComponentReport.default_of, by its short name such as"redis".grelmicro checkadds(provider default)to the line, or(default of redis)when the line names another provider or none. (#1169, #1188) - ✨ Each switching release has its own
GrelmicroFutureWarningsubclass, such asGrelmicro045Warningfor every change that lands in 0.45.0.filterwarnings = ["error::grelmicro.Grelmicro045Warning"]fails a test suite on those changes only. (#1174, #1178)
Changed
- ⚡️
grelmicro.httpand the Starlette integration no longer load the JWT verifier or pydantic-settings at import. (#1167) - ⚡️
Grelmicro(uses=[...])checks a backend against the first-party backend kinds without importing their packages. A Tasks app no longer loadsgrelmicro.outbox. (#1166) - ⚡️ The Starlette integration loads authentication, error responses and route gates only when the app uses them. A Starlette app with
Tasksloads 30 fewer grelmicro modules. (#1170) - ⚡️ The FastAPI integration loads authentication, route rate limits, conditional requests, the response cache and the metrics router only when the app uses them. Importing it no longer loads the JWT verifier or pydantic-settings. A FastAPI app with
Tasksloads 36 fewer grelmicro modules. (#1173) - ⚡️ The Litestar integration loads authentication, error responses and route gates only when the app uses them. Importing it loads 57 fewer grelmicro modules. A Litestar app with
Tasksloads 21 fewer. (#1177) - ✨
micro.describe(),grelmicro checkand the app on startup report a@cached(lock="host")orlock="cluster"fold with noCoordination, or one whose lock backend falls short, and warn withBackendScopeWarning, instagingandproductiontoo. Any app opened in the process reports a decorated fold, andlock="process"or aCoordinationthat reaches far enough silences it. A miss behaves as before. (#1182, #1188)
Deprecated
- 🗑️
EnvLoadDefaultWarningis no longer emitted. Reading it warns withGrelmicro045Warning, and it is removed in 0.45.0. Type checkers report it as deprecated. Filter onEnvLoadOffWarninginstead. (#1183) - 🗑️ The log formats are spelled
auto,json,logfmt,textandpretty, and still match in any case.LogConfig.formatreads back in uppercase until 0.45.0, then in lowercase. Comparing it with a format name warns withGrelmicro045Warning. Type checkers acceptLogFormatvalues in uppercase until 0.45.0. (#1181) - 🗑️
HealthChecks(provider_checks=None)andHealthChecks.from_config(provider_checks=None)read as"report", where 0.43 registered no provider checks, and warn withGrelmicro045Warning.Noneis removed in 0.45.0. Type checkers report it as deprecated. Omit the argument for"report", or passprovider_checks="off"to register none. (#1186, #1188) - 🗑️
grelmicro.integrations.fastapi,grelmicro.integrations.starletteandgrelmicro.integrations.litestarstill serve the names they only imported in 0.43, such asRateLimiter, and warn withGrelmicro045Warning. Those names leave the modules in 0.45.0. Import each one from the module the warning names.AdmissionErrorfromgrelmicro.security.bansandGrelmicroErrorfromgrelmicro.security.jwtdo the same, and import fromgrelmicro.errors. (#1186, #1188, #1190) - 🗑️
gate-backendingrelmicro checkandmicro.describe()stays a warning instagingandproduction, says it fails there from 0.45.0, and warns withGrelmicro045Warningat themicro.describe()line. Register aCoordinationwith a lock or schedule backend, or passbackend=to the task. (#1186, #1188)
Fixed
- 🐛 The
BackendScopeErrorforIdempotencyorIdempotentRequestswith no lock backend says to register aCoordinationwith a Redis, Valkey, Postgres or Kubernetes provider, not to use one of those backends. (#1168) - 🐛
micro.fake()on an app that lists aMemoryProviderregisters oneprovider:memorycheck and no longer logs that it is already registered. (#1185)
Docs
- 📝 The wiring guide shows how to serve only some kinds of a provider by listing its components instead of the bare provider. (#1169)
- 📝 The README and docs tagline is back to "Async-first toolkit. Microservice patterns inside." and the comparison page names no single framework. (#1165)
- 📝 The old URLs of moved and removed docs pages, such as
/reference/clientip/, redirect to the page that replaced them. (#1187)
Internal
- 👷 Pull request CI runs the unit tests on every supported Python, 3.14 and 3.15. The slow and integration tiers stay on 3.14 there and run on both nightly and before a release. (#1172)
- ✅ The multiprocess test harness serves its shared state over localhost TCP, so those tests run inside a sandbox that refuses Unix socket binds. (#1171)
- 👷
just release-audit <version>prints one report to read before a release: changelog policy, public API diff, due deprecations and CI health. (#1179)
0.43.1 - 2026-10-25
Re-cut of 0.43.0, which never reached PyPI. A memory budget test failed the release run on Python 3.15, so publishing was skipped. The 0.43.0 tag is immutable, so its changes ship here. See 0.43.0 for them.
Fixed
- 🐛 On Python 3.15, a circuit breaker no longer loads its
consecutive_countalgorithm module when grelmicro starts, the same as on 3.14. (#1163)
0.43.0 - 2026-10-25
Added
- ✨
micro.describe()lists the scheduled tasks inAppReport.tasks, each aTaskReportwith its schedule, gate, claim name and backend.grelmicro checktext output gains aTasksblock when a task is registered. (#1155) - ✨ A cron or interval task in
tasks.taskshas a read-onlygatethat returns the gate it was built with, the sameLeaderElectionorTaskLockobject included. (#1155) - ✨
grelmicro checkwarns withgate-backendon a gated task that resolves no backend to claim through, and withleader-not-runningon a task gated on an election noTasksholds.gate-backendfails instagingandproductionfrom 0.44.0. (#1155) - ✨
lock.hold(timeout=..., auto_extend=True)extends the lease while the body runs. When the lease is lost, the body is cancelled and theasync withraisesLockNotOwnedError. (#1149) - ✨
SQLiteLeaderElectionAdapterruns leader election on SQLite, sogate=LeaderElection(...)works on aSQLiteProvider. Moving to Postgres or Redis changes the provider line only. (#1148) - ✨
SQLiteProvider(synchronous="normal")andSQLITE_SYNCHRONOUS=normalcommit faster. A power cut or OS crash can roll back the last commits. The default keeps SQLite'sfullmode. (#1154) - ✨
HealthChecks(provider_checks=)picks the check registered for each Provider:"report"shows it in/healthzonly,"require"fails/readyzwhile the backend is down, and"off"registers none. (#1146) - ✨
OpsServer(enabled=False)andGREL_OPS_ENABLED=falseswitch the ops server off. It binds no port, andhealth-not-servedsays it is switched off. (#1142) - ✨
HealthChecks.add_circuitbreakerreports a circuit breaker as acircuitbreaker:{name}check. An open breaker fails it with the seconds until the next probe, and it is non-critical by default. (#1137) - ✨
GrelmicroFutureWarningwarns one minor release before a default changes or a name is removed. Choosing the value explicitly, or switching to the new name, silences it. (#1123)
Changed
- ⚡️
grelmicro.logno longer loadsgrelmicro.resilienceat import. ARateLimitFilterloads its token bucket when it is built. (#1127) - ⚡️ grelmicro packages load most public names on first access, not at import. A FastAPI app with
Tasksloads 24 fewer modules and about 1.1 MB less, and a Redis app with a rate limiter and a circuit breaker 16 fewer and about 1.2 MB less. (#1133) - ✨
TTLCache()with no serializer stores JSON values such as afloat, astror adict, not onlybytes. Bytes are still stored raw. (#1125) - ⚡️
import grelmicrono longer loadsgrelmicro.metrics,grelmicro.traceand most ofgrelmicro.health. Their names load on first use, which saves 24 modules, about 1.9 MB and 10 ms. (#1127) - ✨ A config field that picks a mode takes a string, such as
Metrics(exporter="prometheus"), matched in any case. (#1136) - ✨
environment=,requires=,lock=, the backoffjitter, the log filterkey_mode, the circuit breakerlog_level,gate=, and the OAuthmethod=andaudience=match their choices in any case. (#1136)
Deprecated
- 🗑️ A mode field such as
MetricsConfig.exporterreads back as its old enum member until 0.44.0, then as a plainstr. Its.valueand.namewarn withGrelmicroFutureWarning. Compare it with the string. (#1139) - 🗑️
MetricsExporterType,TraceExporterType,TraceProcessorType,TraceSamplerType,LogLevelType,LogFormatType,LogBackendTypeandLogSerializerTypewarn withGrelmicroFutureWarningand are removed in 0.44.0. Type checkers report them as deprecated. Pass the string instead. (#1136) - 🗑️
GREL_ENVIRONMENT=Production, or any tier in another case, warns withGrelmicroFutureWarning. It reads as undeclared until 0.44.0, then as that tier. (#1136) - 🗑️ A
GREL_*variable set whileGREL_ENV_LOADis unset warns withEnvLoadDefaultWarning, aGrelmicroFutureWarningthat a filter onEnvLoadOffWarningstill silences. It is not read until 0.44.0, then read by default. SetGREL_ENV_LOAD=1orGREL_ENV_LOAD=0to choose now. (#1145) - 🗑️
HealthChecks(auto_health=True)andauto_health=Falsewarn withGrelmicroFutureWarningand are removed in 0.44.0. Type checkers report them as deprecated. Passprovider_checks="require"orprovider_checks="off"instead. (#1146) - 🗑️
HealthChecks()with noprovider_checks=warns withGrelmicroFutureWarningwhen the app opens beside a Provider. From 0.44.0 it defaults to"report". Passprovider_checks=to choose now. (#1146)
Docs
- 📝 Health, Metrics, Deployment and the FastAPI reference recommend
OpsServerfor the probes and/metrics, next to a web app too.health_router()andmetrics_router()stay as the app-port alternative, with what they expose. (#1132) - 📝 Deployment explains how to secure the ops endpoints: what
/healthzand/metricsreveal, why127.0.0.1breaks probes and scrapes, and how to keep the port private on Kubernetes, Docker and behind a reverse proxy. (#1132) - 📝 Contributing and the migration page state what a patch and a minor may ship. A deprecation warns in one minor and switches in the next, never in a patch. (#1128)
Internal
- ✅
benchmarks/memory_benchmark.pymeasures the modules and memory three reference apps load, and a test pins which grelmicro packages each one loads. (#1127) - ✅ The tests set the root path on the test client, not with the deprecated
FastAPI(root_path=...), and no longer silence its warning. (#1140)
0.42.7 - 2026-10-24
Fixed
- 🐛 A Kubernetes service link such as
REDIS_PORT=tcp://..., set for a Service namedredis, is ignored instead of crashing startup with a port parse error. The same holds forGREL_OPS_PORTand every other port variable. A value of that shape that Kubernetes did not set fails with an error naming the variable. (#1147)
0.42.6 - 2026-10-23
Fixed
- 🐛 Two
SQLiteProviders on one file no longer block each other withdatabase is locked, in one process or across services. A file already in WAL opens without switching it again, and a file SQLite keeps out of WAL logs a warning. (#1138)
0.42.5 - 2026-10-23
Changed
- ⬆️ grelmicro and
grelmicro-coresupport Python 3.15.grelmicro[jwt]requiresgrelmicro-core0.1.5, which ships wheels for 3.15 and the free-threaded 3.15t. (#1112) - ⚡️
import grelmicroloads pydantic-settings only when a config first reads the environment, which saves 36 modules, about 1.7 MB and 10 ms. (#1122)
Fixed
- 🐛 A claimed task's first fire and an outbox message waiting at startup find their backends open.
TasksandOutboxopen after every other item inuses=, whatever order it is listed in, so the first run no longer fails withno such tableon SQLite and Postgres. (#1113)
Docs
- 📝 The installation guide warns that Python 3.13 or older installs 0.41.x and states the supported Python window. (#1110)
- 📝 A new page, Coming from APScheduler, slowapi or aioredlock, maps each library to grelmicro and says what changes on more than one replica. (#1116)
- 📝 The README opens with what grelmicro keeps correct on more than one replica, and says Python 3.14+. (#1111)
- 📝 The comparison page opens with the multi-replica case, compares the task scheduler with APScheduler, and shows
micro.install(app)as the lifecycle. (#1111) - 📝 The benchmark numbers are measured again on CPython 3.14, and the JWT speed claim links its measurements and script. (#1111)
- 📝 The PyPI page links the changelog. (#1111)
Internal
- 🐛
benchmarks/ratelimiter_benchmark.pyruns again with a sliding window the config accepts. (#1111)
0.42.4 - 2026-10-22
Changed
- ⬆️
grelmicro[jwt]requiresgrelmicro-core0.1.4, built with PyO3 0.29.3 and aws-lc-rs 1.18.2. (#1108)
Internal
- ⬆️ Every locked dependency is on its latest release, certifi 2026.10.19 among them. Tests run on FastAPI 0.159 and Starlette 1.9. (#1108)
0.42.3 - 2026-10-21
Added
- ✨
route_template(scope)ingrelmicro.httpreturns the route template a request reached. (#1080)
Fixed
- 🐛 On Litestar 2.25, routes gated by
micro.install(app)answer again instead of 500. (#1101)
0.42.2 - 2026-10-20
Fixed
- 🐛 With env reads on,
GREL_CIRCUITBREAKER_{NAME}_IGNORE_EXCEPTIONS,GREL_SHIELD_{NAME}_TIMEOUT_ERRORSandGREL_IDEMPOTENT_REQUESTS_{NAME}_WAIT_TIMEOUTraiseSettingsValidationErrornaming the variable that replaces them, instead of being ignored. (#1095)
Docs
- 📝 Each minor's migration notes open with the changes that do not raise, then those that raise, and 0.33, 0.35, 0.38 and 0.41 have notes. (#1082)
- 📝 The 0.42 migration notes say what a rolling deploy sees while rate limits, leader election, derived task locks and cached responses move keys. (#1083)
- 📝 A worked third-party integration for a small framework,
examples/third-party-integration/, with tests that serve, refuse and cache its declared routes. (#1052)
Internal
- ✅ A test fails when a
💥entry sits outside### Breaking. (#1082) - 👷 The GitHub release becomes Latest only once PyPI has the version. (#1084)
- ♻️ Each integration says how its router reads the path, and the gate names no framework scope key. (#1056)
0.42.1 - 2026-10-19
Breaking
- 💥 On Starlette, a route under a
Mountor aHostwhose app is aMountnames every mount, such as/mm/r/ainstead of/mm/a. (#1070) - 💥 On Starlette, a
Mountor aHostwhose app is aRoutenames that route, such as/t/{tenant}/items/{n}instead of/t/{tenant}/{path}, or/items/{n}instead of none under aHost. (#1070) - 💥
micro.use(p1)thenmicro.use(p2)with no component raisesAmbiguousProviderError, asuses=[p1, p2]does. (#1078) - 💥
Grelmicro(uses=[p1, p2])raisesAmbiguousProviderErrorwhen the app opens or first reads its components, not at construction. (#1078) - 💥 A Provider registers its default components when the app opens or first reads its components. A component of a kind a default holds raises
ComponentAlreadyRegisteredErrorafter that. (#1078)
Added
- ✨ A cron task with no
gate=beside a schedule backend warnsUngatedCronWarning. Passgate="claim"orgate=Noneto silence it. (#1076)
Fixed
- 🐛 Importing the FastAPI or Starlette integration with a framework older than its extra's floor raises
DependencyVersionError, naming the extra to install. (#1077) - 🐛
micro.use(component)aftermicro.use(provider)replaces the provider's default for that kind instead of raisingComponentAlreadyRegisteredError. (#1078) - 🐛 Litestar's
app.openapi_schemadocuments the security, headers and refusals grelmicro adds, frominstallon. (#1071) - 🐛 A Litestar route registered after
installis described. (#1071) - 🐛 On Litestar, each component describes only the responses of the components registered before it, so
IdempotentRequestsregistered beforeAuthenticatedRequestsno longer listsIdempotent-Replayedon the401. (#1071)
0.42.0 - 2026-10-19
Breaking
- 💥 On an app without authentication, a URL no route answers is neither cached nor replayed. (#1055)
- 💥 On an app without authentication,
ConditionalRequestsruns at the route, so an unknown URL gets404and a method the route does not take gets405, not428. (#1055) - 💥 On an app without authentication,
IdempotentRequests(require_key=True)answers an unknown URL404and a method the route does not take405, not400. (#1055) - 💥 An
include=pattern never caches a route under middleware on a mount, a router, a mounted app or the app itself, such as Starlette'sAuthenticationMiddleware, unless the route declaresCachedResponse(). (#1055) - 💥 A
HEADis answered from the storedGETonly on a route that answers both, and aHEADroute of its own runs its handler. (#1055) - 💥 A cache, idempotency or conditional middleware added by hand decides from its patterns and the request headers alone, not from the routes. (#1065)
- 💥
CachedResponsesMiddleware(policies=...)is removed. (#1065) - 💥
grelmicro checkandmicro.describe(app)list the routes the app's integration declares. (#1053) - 💥 A Litestar row names the path as its router does, such as
/items/{item_id}for/items/{item_id:int}. (#1053) - 💥 An
HTTPEndpointroute withoutmethods=gets one row per method it answers. (#1053) - 💥 Routes under a mount the integration gates as one route, such as a router of a class of your own or a mount of a mount, get no row. (#1053)
- 💥
grelmicro check,micro.describe(app)andCachedResponsesmatch aninclude=URL one path segment per parameter, whatever its converter, so/items/abcnames/items/{item_id:int}. (#1053) - 💥
document_idempotency,document_conditional_requestsanddocument_rate_limited_requestsare removed fromgrelmicro.integrations.fastapi. Register the component andmicro.install(app)describes it. (#1020) - 💥
IdempotentRequests,ConditionalRequestsandRateLimitedRequestslosedocument_openapi(), andIdempotentRequestslosesrefresh_routes()androute_is_gated().micro.install(app)describes and routes them. (#1020, #1065) - 💥 The default idempotency key follows the checks each route declares. Starlette route, router or mount middleware, a mounted app of another framework, and Litestar guards, dependencies, hooks and middleware stop replay across callers. A FastAPI route with only grelmicro's own dependencies now replays. (#919)
- 💥
CachedResponsesreads what each route declares instead of walking the routes. (#918) - 💥 A Litestar handler with a guard, a dependency, a hook or middleware is never cached. (#918)
- 💥
CachedResponse()on a route that runs a check of its own, such as a FastAPI dependency that is not grelmicro's, fails install naming the route asGET /path. One declared on a router leaves such a read uncached. (#915, #918) - 💥 Middleware of a mounted Starlette or FastAPI app is a check for the routes under it, so the default idempotency key never replays across callers there. (#918)
- 💥
IdempotencyandIdempotentRequestswithout a lock backend, or on one that reaches less far thanrequires, go through the backend check:BackendScopeErrorinstagingandproduction, a warning when no environment is declared. A duplicate on another replica used to run again with no sign. Register aCoordinationor passrequires="process". (#1034) - 💥
CachedResponsestakeslock=like@cached,"process"by default, instead of folding across replicas whenever a lock backend exists. Passlock="cluster"to keep that. (#1034) - 💥 A duplicate's wait is bounded with
max_wait=, the wordBulkheadandRateLimiteruse.wait_timeout=on anIdempotencycall,run(),IdempotentRequestsandIdempotencyMiddleware, the config field andGREL_IDEMPOTENT_REQUESTS_*WAIT_TIMEOUTare renamed. (#1035) - 💥
BulkheadConfig.max_waitdefaults to0.0and refusesNone, which read as no limit everywhere else. The bulkhead still fails fast by default. (#1035) - 💥
CacheErroris removed. Nothing raised it, so anexcept CacheError:never ran. (#883) - 💥
@cached(lock=...)takes how far concurrent misses fold:"process"(default),"host","cluster"orNone."local"becomes"process",Truebecomes"cluster"andFalsebecomesNone. (#883) - 💥
TTLCache.get_or_setfolds concurrent misses in the process by default, like@cached. Passlock="cluster"to fold them through the lock backend. (#883) - 💥
lock="host"orlock="cluster"with no lock backend raisesOutOfContextErrorinstead of folding in the process. With@cached(ttl=...)it raisesSettingsValidationError. (#883) - 💥
metrics_routermoved fromgrelmicro.metricstogrelmicro.integrations.fastapi, next tohealth_router. (#979) - 💥
@cachedand@limitertake their key template askey_template=, likekey_template="user:{user_id}". A string passed tokey=raisesTypeError. (#998) - 💥
@cached(key_maker=...)and@limiter(key_maker=...)are renamedkey=. The function still receives(func, args, kwargs). (#998) - 💥
IdempotentRequests(key_maker=...)andIdempotencyMiddleware(key_maker=...)are renamedkey=. (#998) - 💥
IdempotencyKeyMakerErroris renamedIdempotencyKeyFunctionError. (#998) - 💥 On FastAPI, FastAPI itself records the request spans and metrics. They follow the OpenTelemetry semantic conventions 1.44 instead of 1.11. (#952)
- 💥 On FastAPI,
http.server.durationin milliseconds becomeshttp.server.request.durationin seconds. The request and response body size metrics are gone. (#952) - 💥 On FastAPI, a request span has
fastapi.dependencies,fastapi.endpointandfastapi.serializationchildren instead ofsendandreceive. A WebSocket span is named likeWS /ws/{room}. (#952) - 💥 On FastAPI,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_*andOTEL_SEMCONV_STABILITY_OPT_INno longer apply to requests. (#952) - 💥 With
TraceorMetricsregistered, FastAPI no longer exports anything itself. Its log records are not exported. (#952) - 💥 FastAPI 0.142.1, OpenTelemetry API 1.45, the OpenTelemetry SDK and exporters 1.44, and the instrumentations 0.65b0 are the new floors.
opentelemetry-instrumentation-fastapileaves theinstrumentationextra. (#952) - 💥 grelmicro and
grelmicro-corerequire Python 3.14. (#952) - 💥 On every framework, an access log added by hand, without
micro.install(app), names no route. (#1021, #1022) - 💥 A request answered before routing, such as a cached response, an idempotent replay or a CORS preflight, records a request span with no route on every framework, also under a mount. Its access record names the route it would reach. (#1023, #1022)
- 💥 On FastAPI, the access record names the route FastAPI's own request span names, for a slash redirect, a frontend route, a mounted app, a
Hostroute and a prefix a proxy stripped too. (#1022) - 💥 Starlette 1.7 is the new floor. On a plain Starlette app,
http.routecarries the route template in the access log and security events. (#956) - 💥 Raise the dependency floors: Pydantic 2.12.4, pydantic-settings 2.12.0, FastDepends 3.0.5, lightkube 1.0, redis 6.0, asyncpg 0.31, orjson 3.11.1, uvloop 0.23.0 and PyYAML 6.0.3.
grelmicro[jwt]needsgrelmicro-core0.1.3. (#952) - 💥 A sliding window counts time in whole microseconds, on every backend, so each decision is exact. Its stored state moves to new keys (
gcra_us:) and new Postgres functions, so limits start empty after the upgrade. A slot is its window divided by the limit, truncated to the microsecond, and a window that gives each request less than a microsecond is refused. (#957) - 💥 A sliding window
windowtakes whole seconds or atimedelta, up to 100 years. A float is refused. Writewindow=timedelta(milliseconds=500)for a window under a second. It reads back as atimedelta, and dumps to JSON as ISO 8601 in days and smaller units ("PT1M"not60,"P400D"). From text it reads"60"or an ISO 8601 duration such as"PT0.5S", never years or months. (#957, #959) - 💥
Lock,ReadWriteLock,LeaderElectionandTaskLocktake their leases as whole seconds or atimedelta. A float is refused. (#959) - 💥 The coordination backend protocols take each lease as a
timedelta, andLeaderRecord.lease_durationis atimedelta. (#959) - 💥 Redis leader election stores its record in whole microseconds under new
le_us:keys, so a leader on the previous version is not seen. Upgrade every worker at once. (#959) - 💥
TTLCache,Cache.ttland@cachedtakettlandstale_ttlas whole seconds or atimedelta, up to 100 years. A float is refused.TTLCacheConfig.ttlreads back as atimedelta. (#959) - 💥 The cache backend protocol takes each TTL as a
timedelta. (#959) - 💥
Idempotency,IdempotentRequests,CachedResponses, itsincludelifetimes andCachedResponsetakettlas whole seconds or atimedelta, up to 100 years. A float is refused. Each config reads it back as atimedelta. (#959) - 💥
every(seconds=...)is renamedevery(interval=...). It takes whole seconds or atimedelta, up to 100 years. A float is refused. Writeinterval=timedelta(milliseconds=500)for an interval under a second. (#959) - 💥 Cron
misfire_grace_secondsis renamedmisfire_grace. It takes whole seconds or atimedelta, up to 100 years. A float is refused. (#959) - 💥
CircuitBreaker.consecutive_countandConsecutiveCountConfigtakereset_timeoutas whole seconds or atimedelta, up to 100 years. A float is refused.ConsecutiveCountConfig.reset_timeoutreads back as atimedelta. (#959) - 💥
CircuitBreakerStrategy.transitiontakescool_downas atimedelta. (#959) - 💥
ClientBanstakeswindowanddurationas whole seconds or atimedelta, up to 100 years. A float is refused.ClientBansConfigreads both back as atimedelta. (#959) - 💥
JWTVerifiertakesttlandcache_ttlas whole seconds or atimedelta, up to 100 years. A float is refused.cache_ttl=0still turns the cache off. (#959) - 💥
Outboxtakeslease_durationandpurge(older_than=...)as whole seconds or atimedelta, up to 100 years. A float is refused. (#959) - 💥
keep_deliveredtakes a duration,0to delete on delivery orNoneto keep forever. A bool is refused. (#959) - 💥 The outbox backend protocol takes
claim(lease=...)as atimedelta, andpurge(before_seconds=...)becomespurge(older_than=...), atimedelta. (#959) - 💥
DuplicateFiltertakesttlas whole seconds or atimedelta, up to 100 years. A float is refused.DuplicateFilterConfigreads it back as atimedelta, andnonefrom text means no time limit. (#959) - 💥
HealthCheckstakescache_ttlas whole seconds or atimedelta, up to 100 years. A float is refused.cache_ttl=0still turns the cache off. (#959) - 💥
CircuitBreaker.consecutive_countandConsecutiveCountConfigtakewhen=instead ofignore_exceptions=. It names the errors that count as failures, everyExceptionby default, and any other outcome counts as a success. (#996) - 💥
Shield, its presets and its profile configs takewhen=instead oftimeout_errors=, and it is required.TimeoutErroralways counts as transient. (#996) - 💥 The bare
@shieldis removed. Write@shield.api(when=...). (#996) - 💥
GREL_CIRCUITBREAKER_{NAME}_IGNORE_EXCEPTIONSandGREL_SHIELD_{NAME}_TIMEOUT_ERRORSbecomeGREL_CIRCUITBREAKER_{NAME}_WHENandGREL_SHIELD_{NAME}_WHEN. (#996) - 💥
TaskLock.refresh()is renamedTaskLock.extend(), likeLockandReadWriteLock. (#997) - 💥 A backend failure while extending a lease raises the new
LockExtendErroronLock,ReadWriteLockandTaskLock. (#997) - 💥
LockNotOwnedErroris aCoordinationError, no longer aLockReleaseErroror aLockBackendError. Catch it by name whereexcept LockReleaseErrorcaught a lost lease. (#997) - 💥 The
grelmicro.lock.renewalsmetric is renamedgrelmicro.lock.extensions, with the unit{extension}. (#997) - 💥
GREL_SHIELD_{NAME}_PROFILEandGREL_SHIELD_PROFILEare no longer read. A Shield refuses to build while either is set, and a live reload refuses a mountedPROFILEkey. Choose the preset in code withShield.internal,Shield.apiorShield.slow. (#1002) - 💥
TaskLock()has no name by default, instead of"default". As thegateof a task it takes the task name. Used on its own it raisesSettingsValidationErrorwhen entered, so two unnamed locks never share one. ATaskLock("default")gate keeps that name. (#1001) - 💥 A task
name=that a gate locks under cannot start withtask-, which is reserved for the lock names of tasks named after their function. (#1001) - 💥
TaskLockandLeaderElectionrefuse a name that is not a valid lock name, likeLock. A task whose automatic name is not one, such as__main__:jobin a script run directly, locks undertask-__main__:job. That is also its gate log label and itsgrelmicro.lock.namemetric label, so update dashboards that filter on the old one, and upgrade every worker running such a task at once. (#1001) - 💥 Every provider method that builds a backend ends in
_backend:lock_backend(),readwritelock_backend(),leaderelection_backend(),schedule_backend(),cache_backend(),outbox_backend(),ratelimiter_backend()andcircuitbreaker_backend(). (#1000) - 💥
Coordinationspells read-write lock and leader election in full: thereadwritelock=andleaderelection=keywords, and thereadwritelock_backendandleaderelection_backendproperties. (#1000) - 💥 A custom
Providerthat defineslock(),cache()or another pattern name without the matching_backend()factory fails at class definition, naming the rename. (#1000) - 💥 Leader election adapters register under the
grelmicro.coordination.leaderelection.adaptersentry-point group. (#1000) - 💥
everyandcrontake onegate=saying which workers run a task:None,"claim"for one worker per interval or fire, or aLeaderElection.everyalso takes a tunedTaskLock.lock=andleader=are gone. (#879) - 💥
cronruns on every worker by default, aseverydoes. Passgate="claim"to keep running each fire once across the fleet. (#879) - 💥 A gated task with no schedule or lock backend reports a coordination error and runs nothing, instead of running on every worker. (#879)
- 💥 A
TaskLockgate whosemin_hold_durationis shorter than the interval is refused, at construction and onreconfigure, and so is one already gating another task. A leader election passed assync=is refused too. (#879) - 💥
subis always required, asexpis, so a token with no subject, anullone or one that is not a string is rejected withmissing-claimand every verified caller has a subject to be keyed by. (#839) - 💥 A token declaring a
typother thanJWT,JOSEorat+jwtis rejected withtype, so a DPoP proof or a logout token signed by the same keys no longer passes as an access token.token_type="at+jwt"requires the RFC 9068 type. (#850) - 💥 A token carrying a
cnfclaim is rejected withbinding, because nothing checks the proof of possession it depends on. (#850) - 💥 The
subjectrejection reason is gone. No policy ever set a subject, so nothing produced it. (#850) - 💥
JWTKey.pem(),JWTKey.secret()andJWTKey.jwk()build a key, andJWTKey.from_jwk()is gone. A secret goes with anHS*algorithm and nothing else, so a PEM can no longer be read as an HMAC secret, and a secret shorter than its hash output is refused, as RFC 7518 requires. (#850) - 💥
JWTVerifier.keys()builds a verifier from keys you hold, andJWTVerifier.from_config()builds one from a config that is already whole.JWTVerifier(config)is gone, andJWTConfigis nowJWTKeysConfig. (#850) - 💥
audienceis required. Name the audience your tokens carry, or passaudience=Noneto answer to none, which refuses any token that names one. An empty list is refused, because it says neither.audienceandissueralso take a single string. (#850) - 💥
JWKSVerifieris gone.JWTVerifier.jwks(url, audience=...)builds the same verifier, andJWTVerifier.from_config()takes aJWKSConfigtoo. A verifier built from keys you hold answersready,staleandrefreshas well, so code written against one takes the other. (#850) - 💥
JWKSUnavailableErroris nowSigningKeysUnavailableError, and verifying before any key set has loaded raises it instead ofOutOfContextError.JWKSFetcheris nowFetcher, andunverified_headeranswers before any key set loads. (#850) - 💥
bans=andclient=are gone fromJWTVerifier. Ban callers with aClientBansbeside the verifier: checkbanned()before verifying, andrecord()a rejection after. (#850) - 💥
ClientBans(failures=..., window=..., duration=...)takes its settings as keywords, andClientBans.from_config()takes aClientBansConfig. (#850) - 💥
ClientBannedErroris anAdmissionErrorcarryingretry_after, andClientBans.banned_for()says how long a ban has left.ErrorResponsesanswers it with429, theclient-bannedtype and aRetry-Afterheader. (#850) - 💥
malformedno longer counts toward a ban. It is refused before any signature is checked, and a legitimate client presenting an opaque token lands there. (#850) - 💥
unverified_header(token)is a function, and the verifier method is gone. It reads no key, so it never needed a verifier. (#850) - 💥
JWTClaims.rawis nowJWTClaims.claims, the namePrincipalreads the claim set by. (#850) - 💥
JWTClaims.claimsis read-only all the way down: a nested object is a read-only mapping and an array is a tuple, andJWTClaims.audienceis a tuple when a token names several audiences. A cached claim set is shared by every request presenting the token, so a handler can no longer change what a later one is authorized as. (#850) - 💥 A
Lock,TaskLock,ReadWriteLock,LeaderElectionor gated@cronholding a backend of its own is checked against the backend scope too. The next app to open reports it, andcheck_backends()does as well. Register its backend asCoordination(lock=backend, requires="process")to declare a per-process lock. (#880) - 💥
IdempotentRequestsandIdempotencyrequire scopeclusterand are checked against theCachethey store through, so a memoryCacherefuses to start inproduction. Passrequires="process"to declare a single-process deployment. (#880) - 💥
Cache,Coordination,Outbox,RateLimiterComponentandCircuitBreakerComponentrefuse a first argument that is neither a Provider nor one of their backends, and name the component a backend of another kind belongs to. (#880)
Added
- ✨
grelmicro checkandmicro.describe(app)warnroutes-not-declaredwhen a component acting per endpoint is registered and the app's integration serves HTTP withoutroute_declarations. (#1053) - ✨ With
AuthenticatedRequests,micro.install(app)raisesMiddlewarePlacementErrorfor an integration withoutinstall_middleware, and warnsRoutesNotDeclaredWarningfor one withoutinstall_route_gate, where every route outsideexclude=answers401to a request without a credential. (#1054) - ✨
CachedResponse(shared=True)serves one stored response to every caller a route requiring a caller admits, a credential included. WithAuthenticatedRequests, a route requiring a caller needs it to be cached. (#918) - ✨ Install refuses
shared=Trueon anAnonymous()route, on a router, an include or the app, and on a handler taking the caller as a parameter. A stored response is keyed by what its route requires. (#918) - ✨ A Litestar app publishes the same
429,Idempotency-Keyand precondition headers in its OpenAPI schema as the same FastAPI app. (#1020) - ✨
RouteDeclaration.precondition_requiredsays a route answers428to a request carrying neitherIf-MatchnorIf-None-Match. FastAPI sets it fromConditionalRequired. (#1020) - ✨ A registered
HealthChecksthat nohealth_router(),health_asgi()orOpsServerserves warnsHealthNotServedWarningonce the app opens, anddescribe()reports it. Quiet indevelopmentandtest. (#1033) - ✨
async with lock.hold(timeout=5) as held:waits at mosttimeoutseconds for the lock, then holds it for the body.ReadWriteLock.read,ReadWriteLock.writeand everyfrom_threadadapter have it too. (#1032) - ✨
JWTVerifier.from_configtakesname=likekeys,jwksanddiscover, andJWTVerifier.namereturns it. (#999) - ✨
Trace.from_configtakesinstrument=, andShield.from_configtakestime_source=andrandom_source=, like their constructors. (#999) - ✨
HealthChecks(liveness=Liveness(...))catches a stuck worker in every deployment. A loop watchdog and critical checks markedliveness=Truemake it exit, so whatever runs it starts another.HealthChecks.is_alivesays whether the last round passed. (#983) - ✨
FakeVerifierandfake_claimsingrelmicro.testingstand in for aJWTVerifierin a test: each token is a name for the claims it carries. (#981) - ✨ Starlette and Litestar requests get the request span and the HTTP server metrics FastAPI records, exported by
TraceandMetrics. (#964) - ✨
Trace(instrument=...)takes"starlette"and"litestar", andOTEL_PYTHON_STARLETTE_EXCLUDED_URLSandOTEL_PYTHON_LITESTAR_EXCLUDED_URLSleave URLs out. (#964) - ✨ A FastAPI app mounted under a Starlette app is one request span, named by its full route. (#964)
- ✨
Metricsexportshttp.server.request.durationandhttp.server.active_requestsfor every FastAPI request, with or withoutTrace. (#952) - ✨
OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN=logskeeps an unhandled exception off the FastAPI request span. (#952) - ✨
RateLimitedRequests(flood=...)limits every request before routing, a URL no route answers included, with a budget of its own. A request a route refuses spends no route limit. (#943) - ✨ The FastAPI integration ships
install_route_gate(app, gate)androute_declarations(app). (#915) - ✨ On FastAPI, a route is gated under each include with what that include adds, and so are frontend routes, websocket routes and the routes an include copies. (#915)
- ✨ On FastAPI, a route added after install, to the app or to a router it includes, is gated as it lands. (#915)
- ✨ A FastAPI app mounted under a Starlette app is gated route by route, and its
Anonymous()routes serve a caller with no credential. (#915) - ✨ A mounted app's own middleware runs for a request without a credential when the app holds an
Anonymous()route, and its CORS middleware answers a preflight. (#915) - ✨ With
AuthenticatedRequests, a URL or a method no route answers gets the401of a protected route, and the rate limit, the response cache, idempotency and conditional requests run once the route admitted the request. (#915, #916) - ✨ The Litestar integration ships
install_route_gate(app, gate)androute_declarations(app). (#916) - ✨ On Litestar, each handler is gated per method. (#916)
- ✨ On Litestar, a handler registered after install is gated as it lands. (#916)
- ✨ On Litestar, authentication passed to
Litestar(middleware=[...])checks each handler's declaration and runs the rate limit, the cache and idempotency once it admitted the request. (#916) - ✨
gate(..., name=...)names a refusal from the request. (#916) - ✨ Gating an app a gate already returned counts its declarations again and returns it unchanged. (#916)
- ✨
Gateingrelmicro.httptypes the gate an integration'sinstall_route_gate(app, gate)receives.gate(app, *declarations, name=None, door=False)returns the app the router dispatches to, and picks the declaration by the request's method. (#916) - ✨
RouteDeclarationsays what a route requires, and an integration declares its routes withinstall_route_gate(app, gate)androute_declarations(app), whichmicro.install(app)feature-detects. A listed route without a gate stops the app from starting. The Starlette integration ships both. (#914) - ✨ On Starlette, a route added through the app or any of its routers is gated as it lands, and a route list, a mount's app or a router's default assigned later is gated before it serves. (#914)
- ✨
micro.fake()works on an app thatinstallopens: enter it before the test client starts. The Providers only faked components used stay closed, so the suite connects to nothing and/readyzdoes not probe them, andfake(keep=[provider])opens one for real. (#881) - ✨
Coordination(MemoryLockAdapter())puts a backend in the slot it serves, asCoordination(lock=...)does. (#880) - ✨ A task gated on a
LeaderElectionthat has not run for a lease duration warns once withLeaderNotRunningWarningand a log record, naming the task, the election andtasks.add_task(election). Such an election never becomes leader, so every fire of the task was skipped with nothing logged. (#878) - ✨
OAuthClientgets the tokens a service calls other APIs with:ClientCredentialsfor the service itself andTokenExchangefor the user it is serving, cached, refreshed before they expire, and sent throughauth()withhttpx. The service authenticates with a secret, a private key signed in the compiled core, or a workload identity token read from a file.OAuthClientConfig,ClientCredentialsConfigandTokenExchangeConfighold the settings,ClientAuththe client authentication,AccessTokenthe token, andClientRejectedErrorandTokenUnavailableErrorthe failures. (#859) - ✨
CurrentTokenhands a FastAPI handler theVerifiedTokenthat verified, andcurrent_token(request)does the same on Starlette and Litestar, so a call made for the user exchanges the token that was checked. (#859) - ✨ Authentication writes a security event for every refusal and every ban on
grelmicro.security.events, andgrelmicro.authentication.attempts,grelmicro.authorization.refusals,grelmicro.client_bans.startedandgrelmicro.client_bans.activecount them.AuthenticatedRequests(enduser=True)names the caller on the server span. (#873) - ✨
AccessLog(enduser=True)writes who called asenduser.id, the subject of an authenticated caller, so one record says who asked for what and what they got. Off by default, because a subject can be personal data. (#872) - ✨
AuthenticatedRequests(resource=...)publishes the service's protected resource metadata (RFC 9728) and addsresource_metadatato every bearer challenge, so a client that discovers authorization at runtime, such as an MCP client, finds the authorization server from the service's URL alone. (#857) - ✨
AuthenticatedRequests(check=...)checks every verified caller before the app sees it, cached tokens and websocket handshakes included. ReturnNoneto refuse a revoked token with401and the newrevokedreason, or return your own user object for every route to read. (#858) - ✨
micro.install(app)refuses a route requiring a scope that is not an OAuth scope token, and a route inexcludethat requires scopes. A route inexcludethat requires a caller and no scope installs and answers401. A route declaringAnonymous()that requires a caller fails withTypeErrorwhen it is added. (#839, #917) - ✨ Starlette endpoints require scopes with
@Authenticated(scopes=[...]), on a function, anHTTPEndpointmethod or a websocket, answering401or403with the challenge. (#839) - ✨
grelmicro check,micro.describe(app)and the OpenAPI schema read each route's authentication from its declaration:authenticatedwith its scopes in alphabetical order, oranonymous. A route it authenticates claims no cached or replayed response, and anAnonymous()route stays cacheable and replayable. (#839, #917) - ✨ Litestar handlers declare how they are authenticated:
opt=Anonymous()makes the credential optional on one, andguards=[Authenticated(scopes=[...])]answers401or403with the challenge.Anonymous()is Litestar's ownexclude_from_authkey, so a handler written for Litestar's authentication middleware is public too. (#839) - ✨
AuthenticatedRequestsdescribes itself in the FastAPI and Litestar OpenAPI schema: its security scheme, the scopes each route declares, and the401,403and429it answers.openapi=Falseleaves the schema alone. (#839) - ✨ FastAPI routes declare how they are authenticated:
CurrentPrincipalandClaimshand a handler the verified caller,Authenticated(scopes=[...]), built onfastapi.Security, answers403for a missing scope, andAnonymous()makes the credential optional on a route, verifying a token that is sent, withOptionalPrincipalhanding the handler the caller orNone. (#839) - ✨
AuthenticatedRequests(verifier, exclude=...)authenticates every request and websocket handshake with a bearer token, and puts the verified caller inrequest.userandrequest.auth.AuthenticatedRequestsConfigholds its settings, andAuthenticatedRequestsMiddlewareis the same for a hand-built stack. (#839) - ✨ An
exclude=prefix that ends inside a path segment, such as/public*, is refused. Write/public/*or the exact path/public. (#922) - ✨
ErrorResponsesrenders authentication refusals with their RFC 6750WWW-Authenticatechallenges:AuthenticationRequiredErrorandTokenRejectedErroranswer401,AmbiguousCredentialsError400andInsufficientScopeError403.SigningKeysUnavailableErroranswers503. The401names no scope, and the403names the missing ones in alphabetical order. (#839) - ✨
JWTVerifier.discover(issuer, audience=...)finds the JWKS URL in the issuer's RFC 8414 authorization server metadata or OpenID Connect discovery document, so one URL configures a verifier. The document must name the issuer exactly and anhttpskey set, and it is read again once perttl. While the metadata cannot be fetched, the key set it last named is still refreshed.DiscoveryConfigis the config it builds. (#849) - ✨
Principalis the caller a handler reads, whatever proved who it is, andJWTClaimssatisfies it. A verified token also answersis_authenticated,identityanddisplay_name, which Starlette reads offrequest.user. A caller is authenticated only when itsis_authenticatedisTrue. (#850) - ✨
scopesis read fromscope, thenscp, as a space-separated string or an array of strings, so Microsoft Entra ID and Okta tokens both grant their scopes.scope_claimsnames other claims. (#850) - ✨
JWTVerifierandClientBansread their settings from the environment and fromExternalConfig, underGREL_JWTVERIFIER_andGREL_CLIENTBANS_.name=addresses a second one. (#850) - ✨
JWTVerifieris an async context manager.async with verifier:loads the keys before the first request and refreshes them in the background everyretry_interval. (#850) - ✨
TokenRejectedError.reasonis aTokenRejectedReason, one member per way a token fails. Each member compares equal to the string it names, soerror.reason == "expired"keeps working, and a tag the module does not know reads asINVALID.TokenRejectedError.subjectnames thesubof a token whose signature verified. (#850)
Fixed
- 🐛
python -m grelmicro checkexits2and names the error when the app module raises while it imports, instead of printing a traceback and exiting1. (#1068) - 🐛 A route under a mount of a router's bound
appno longer reads as cached by aninclude=pattern ingrelmicro check, which never caches it. (#1053) - 🐛 A FastAPI route declaring
CachedResponse()underapp.mount("/b", router.app)is cached. (#1053) - 🐛 A path under a mounted app, such as
/static/a.cssno longer warns ingrelmicro check. (#1053) - 🐛 A pattern naming a read that runs checks of its own, a FastAPI security scheme included, leaves it uncached instead of failing install. (#918)
- 🐛
app.dependency_overridesno longer changes what is cached. (#918) - 🐛 A
POSTroute injectingConditionalRequired, or named inrequire_precondition, marksIf-Matchrequired in the OpenAPI schema with its412and428. (#1020) - 🐛 A
Lock,ReadWriteLockorTaskLockrelease finishes on the backend when its task is cancelled, so a client disconnect or a shutdown no longer leaves the lease held until it expires. A release the backend never answers gives up afterlease_duration, and so does aTaskLockextend. (#883) - 🐛 A
ReadWriteLockwriter cancelled while it waits withdraws its intent, even when cancelled again, so readers no longer wait for the intent to expire. (#883) - 🐛 The
grelmicro.lock.holdersgauge drops when a task is cancelled while it releases its lock. (#883) - 🐛
RateLimitedRequestsbuilt with no limiter, or with neithertrusted=norkey=, raises an error that names it and says how to fix the call. (#883) - 🐛 A cache miss or a
CachedResponsesrequest folding through a lock backend that fails still runs and logs the failure, and one that outlived its lock returns what it stored instead of raisingLockNotOwnedError. (#883) - 🐛
IdempotentRequests(env_load=...)decides whether its TTL is read fromGREL_IDEMPOTENCY_HTTP_TTL, andIdempotentRequests.from_configreads no variable for it. (#959) - 🐛 A Postgres lock is held for every worker whatever its session time zone, so a worker in another zone no longer takes a held lock. Setup converts the lock table once. Run every worker in the same session time zone while upgrading, since a live lease written in another zone shifts by the difference. (#992)
- 🐛 A lease is never shorter than asked. Redis rounds a lock lease up to the millisecond and keeps a leader lease to the microsecond. SQLite holds a lease to the millisecond, not up to two seconds longer. (#959)
- 🐛 An open circuit never probes before its reset timeout ends. Redis, Postgres and SQLite compare it in whole microseconds, and Redis keeps a circuit opened with a longer
cool_downfor all of it. (#959) - 🐛 A cache entry is never kept shorter than its TTL. Redis rounds a TTL up to the millisecond, so a TTL under a millisecond is stored instead of failing the write. (#959)
- 🐛 A ban is never shorter than its
duration, and a cached token and a key set are never kept past their setting. Each is timed in whole nanoseconds, not float seconds. (#959) - 🐛
JWTVerifierrefuses atimeoutorretry_intervalthat is not a finite number when they are built. (#959) - 🐛
DuplicateFilterlets a repeated record through once its key has been silent forttl, timed in whole nanoseconds. A key silent for exactlyttlwas still dropped until the next sweep. (#959) - 🐛
HealthChecksrefuses atimeoutthat is not a finite number of seconds above zero, or is a bool, as its default and on each check. (#959) - 🐛
Livenessrefuses aninterval,stall_timeoutorshutdown_timeoutthat is not a finite number above zero. (#959) - 🐛
add_contextin a request or message handler adds its fields to the handler's log records. It did nothing unless the handler opened a span. (#973) - 🐛 On a Litestar app without middleware of its own, a raised
HTTPException, or an exception the app maps to a response with a handler, is stored and replayed by idempotency, and carries theRateLimitfields. (#945) - 🐛 A request under a Starlette mount records the mount template in
http.routein the access log and security events, not the values in its path. (#969) - 🐛 A request to an app mounted under FastAPI, to an ASGI app mounted in Litestar, to an app wrapped in middleware under a mount, or to an app mounted in an installed Litestar app records the mount template in
http.routein the access log and security events, as the request spans do. (#969) - 🐛 On Starlette and Litestar, the access log and security events name the route the request span names, a prefix a proxy stripped included. A request through two installed apps writes one access record. (#1021)
- 🐛
Traceleaves a framework's own OpenTelemetry instrumentor out of its library sweep only whenmicro.install(app)wired an app of that framework. (#968) - 🐛 A FastAPI app with an OTLP endpoint in the environment no longer sets up a second exporter beside grelmicro's.
OTEL_EXPORTER_OTLP_PROTOCOL=grpcandOTEL_TRACES_EXPORTER=consoleno longer fail its startup. (#952) - 🐛 The published floors install and work. pydantic-settings 2.5 failed at import, the Redis instrumentation crashed before 0.54b0, and Pydantic 2.12.0 to 2.12.3 read some environment variables in the wrong order and percent-encoded URL credentials. (#952)
- 🐛 A sliding window refuses a request that arrives before its slot. With 10 per second, an 11th request was admitted up to 50 ms early. (#957)
- 🐛 The
RateLimitandX-RateLimit-Resetheaders no longer state one second too many until the quota is back. A20second window could reportt=21. (#950) - 🐛 On FastAPI,
http.routein the access log carries the prefixes of the routers a route is included by. (#953) - 🐛 A Litestar app mounted under another Litestar app serves its routes with idempotency, the response cache, rate limiting and conditional requests installed, and records no
http.routefor a URL it has no route for. (#947) - 🐛 On Litestar, idempotency stores nothing for a handler that raises an unhandled exception when the app declares middleware of its own, so the retry runs the handler again instead of replaying the
500. (#942) - 🐛 The
"default"backend of a kind is the Component named"default", or the only Component of that kind when none is, as Backends documents. A pattern,micro.get(kind),micro.fake()and the backend scope check all resolve it this way. (#877) - 🐛 A
Cacheand aCoordinationsharing one SQLite file start together. The cache created its tables while another component held a transaction open on the shared connection, which failed withcannot commit transaction - SQL statements in progress. The rate limiter and the circuit breaker created theirs the same way. (#752) - 🐛 Two processes opening the same SQLite file at the same time no longer fail with
database is locked. The provider retries while the other process sets the file up, and every later write waits for its turn. (#752) - 🐛 Opening an app that is already open says so, and names
install(app)andmicro.fake(). (#881) - 🐛
install(app)called twice on one app wires it once. (#881) - 🐛 A claimed interval task whose
synclock refuses the body reports the fire asmissed, as a cron task does, instead ofskipped. The claim holds the interval, so no other worker runs it. (#910) - 🐛 An interval task claimed with a
TaskLockruns once per interval across the fleet. The claim used to free one second after the body ended, so replicas with offset timers each ran their own tick. (#879) - 🐛 A
TaskLockwhose hold ran out on its own clock lets the same instance back in, even when the backend stores lease times in whole seconds (SQLite, Kubernetes). A claimed interval task on those backends no longer skips every other interval. (#879) - 🐛 A gated interval task renews its claim while the body runs, so a long body never lets a peer run the same interval. The lease now only bounds how long a crashed worker keeps the claim. (#879)
- 🐛 The
TaskLockpassed as a gate is the one the task holds, soreconfigure()on it reaches the running task. (#879) - 🐛
NoActiveAppErrorarrives with a message instead of none at all. It namesmicro.install(app)and theasync with micro:block, and its docstring covers the request handler that reaches it as well as the call made outside any block. (#889) - 🐛 A pattern that resolves no backend outside any app adds that
micro.install(app)covers request and message handlers, and not a lifespan of your own, so a caller who did install the app learns why the call still missed. Inside an open app, the message names only registering the missing component. (#889) - 🐛
IdempotencyandIdempotencyMiddlewarereport a cache backend that is not open as it is, instead of telling a caller who passedcache=to passcache=. (#889) - 🐛 Errors that carried no action now name the fix:
ComponentNotRegisteredError,TaskAddOperationError, the lock backend errors, the precondition and credential refusals, closing an app that is not open, and everyOutOfContextErrorthat names a method. (#889) - 🐛 The
412detail no longer tells every client itsIf-Matchtag went stale. It covers a refusedIf-None-Matchtoo, such as a create-only write on a resource that exists. (#889) - 🐛
JWTClaims.expires_atandissued_atare whole seconds for a token whoseexporiatcarries a fraction, rounded down, instead of afloattheirinttype never announced.claimskeeps the value as it arrived. (#871) - 🐛 On Litestar, an
includeorexcludepattern matches the path its router serves, with a trailing slash or a doubled slash dropped, whether or not the app has a root path.exclude=("/livez",)covers/livez/too. (#839) - 🐛
CachedResponsesandIdempotentRequestsserve an app that mounts routes naming a parameter the mount names too, such as/members/{id}under/orgs/{id}. They failed withValueErrorwhere Starlette serves it. (#839) - 🐛 On Litestar, grelmicro's middleware answers in the order it was registered, the first one outermost, as on Starlette and FastAPI. It was nesting in reverse. (#839)
- 🐛 A JWKS endpoint that cannot be reached, or times out, raises
SigningKeysUnavailableErrorinstead of a rawhttpxerror.async with verifier:then opens without keys as documented, and the background refresh keeps retrying instead of stopping for good. (#850) - 🐛 A JWKS entry that is not a key object, names its
ktywith anything but a string, or holds key material the core cannot read, is skipped and the document's other keys still load, so one bad key no longer stopsasync with verifier:or ends the background refresh. (#850) - 🐛 A missing
httpxraisesDependencyNotFoundErrorfromasync with verifier:instead of being retried forever, and an unexpected error in the background refresh is logged without ending it. (#850) - 🐛
ClientBans.record()no longer reports a ban that has already run out. (#850) - 🐛 A full
ClientBanstable evicts the least recently recorded client, as documented, instead of the one that failed first, and a clientforget()cleared no longer takes up room or gets a later entry evicted. (#850) - 🐛 A failure from an address
ClientBansalready tracks no longer evicts another address, so a full table can no longer be made to drop someone else's ban. (#850) - 🐛 A Postgres lease keeps its exact length across a daylight saving change. (#990)
Security
- 🔒 A token whose
jtiis not a string is rejected withmalformed, one whoseiatis not a number withinvalid, and one whoseiatis still to come withnot-yet-valid, so a handler never reads a number as a subject. (#839) - 🔒
JWTKeyholds its key material as a secret, so arepr, a log line or a dumped configuration never shows it. (#850) - 🔒 On Litestar, middleware added with
Litestar(middleware=[...])matchesincludeandexcludeagainst the path the request was routed to. The root path is taken off once, and a mount is matched by its own path, soexcludecan no longer match a path inside a mount. (#839) - 🔒 A request's route path is read exactly as the framework's router reads it, a
root_pathending in a slash or equal to the path included, so the response cache, idempotency and the other components never check a route other than the one that answers. (#839) - 🔒 The response cache and idempotent replays read a URL ending in a newline as the route Starlette serves it with, so a gated read is never cached, and a gated write never replayed, through that spelling. (#839)
Performance
- ⚡ One refresh fetches at a time. A caller arriving while one runs waits for it, so a burst of tokens naming a new key costs the provider one request, and a request refused with
unknown-keycan awaitrefresh()and verify again. (#850) - ⚡ Importing a grelmicro package no longer loads FastAPI or Starlette, and only
grelmicro.logloads OpenTelemetry. (#975) - ⚡ Redacting a URL with nothing to hide returns it without parsing it. A clean URL with a query string costs about 0.6 µs instead of 8 µs, and one with no query about 0.3 µs. (#841)
Docs
- 📝 The deployment page says to run one worker per container, and how to catch a blocked worker when a container runs several. (#984)
- 📝 The health page says why
/livezruns no check, and which failures it still catches. (#982) - 📝 The bulkhead page says each
Bulkheadobject counts its own calls, in one process, not across replicas. (#977) - 📝 The landing page starts with one pattern, a job that runs once across replicas, then adds the next one and states one guarantee per module. (#884)
- 📝 Coming from Spring Boot, Quarkus, Django, Laravel or Symfony: one page each maps the concepts, the false friends and what has no equivalent. (#884)
- 📝 The JWT guide shows how to sign a test token. The pages define their jargon where it first appears. (#884)
- 📝 The package summary and keywords describe what grelmicro covers. (#902)
- 📝 CONTRIBUTING shows how to title an issue. (#891)
- 📝 What each kind of flood meets, and why client bans are opt-in. (#943)
- 📝 The snippet files run and show something: a FastAPI app answers requests, a snippet that defines
main()calls it, and the logging examples print what the page shows. A block that only runs inside anasyncfunction is labelledfragment. (#888) - 📝 The testing guide shows one fixture for the app you ship, and app factories cover two apps or clients at once. (#881)
- 📝 The Authentication page covers
AuthenticatedRequests, and Where a rule applies counts it among the HTTP components. (#839) - 📝 JWT verification is documented where people look first. The README lists it under a Security module marked Rust powered, Installation lists the
jwtextra and the platforms its wheels cover, and the security overview and the roadmap no longer call it future work. (#738) - 📝
llms.txtis a curated index again, and the newllms-full.txtlists every page of the site. A test asserts that each page in the nav appears in one of them, so a new page cannot go missing. (#752) - 📝 The Bulkhead, Fallback and Timeout pages name
RateLimiterin the composition order, matching Composing patterns andStack. (#752) - 📝 Backends and adapters says which error a Pattern resolved outside the app raises, and teaches
micro.install(app)instead of adding the middleware by hand. (#752) - 📝 Graceful shutdown says what a force-cancelled lock holder releases. A task cancelled in its body still releases, and a cancel that lands during the release leaves the lease to expire by TTL. (#752)
- 📝 Declarative configuration says the resolution rule covers the programmatic and environmental paths, and that
from_configskips the env layer. (#752) - 📝 Configuration internals describes live reconfiguration as shipped, not as future work. (#752)
- 📝 The
@cachedpage and docstring say thettl=form takes async functions only, and that a sync function needs aTTLCache. (#752) - 📝 Testing says how to build a verifier for a test instead of pointing at a JWT section that does not show it. (#752)
- 📝 The README describes the Task Scheduler as it behaves: every worker runs a task unless
gate=picks one. (#752)
Internal
- ✅ The snippet tests exercise each FastAPI snippet's routes, run the snippets that need no service, and compare a page's output block with what the snippet prints. (#888)
- ♻️ One shared duration type takes whole seconds or a
timedelta, and a test fails when a*Configgains a float ortimedeltafield that no list names. (#959) - 👷 CI runs the tests on the oldest version of every dependency grelmicro allows. (#952)
- ✅ The test that imports the FastAPI integration without FastAPI restores the module afterwards, so later tests patch the module the app reads. (#1074)
- 👷 One release ships
grelmicroandgrelmicro-core. When PyPI lacks the crate version, the release builds the core and publishes it first, sogrelmicro[jwt]always resolves. Ajwtpin that names another version, or a crate changed after its version was published, fails the pull request. (#875)
0.41.1 - 2026-09-12
Added
- ✨
JWTVerifierverifies the bearer token a caller presents, against a fixed key set and claim policy. Signature,exp,nbf,audandissare checked in a compiled core, so a verification costs about 12 microseconds where a pure-Python library costs 46. Install it withpip install "grelmicro[jwt]", which pulls the compiledgrelmicro-corewheel. (#840) - ✨
JWTConfig.from_jwks()builds a verifier from the JWKS an OIDC provider publishes, reading thekidandalgoff each key and skipping the ones published for encryption. AWS Cognito and Microsoft Entra ID both work, including Entra keys that carry noalg. (#840) - ✨
JWKSVerifierfetches the JWKS itself and follows the provider when it rotates.refresh()is a coroutine you schedule andverify()stays synchronous, so no request waits on the provider. A token naming a key the verifier does not hold is refused and marks the key set stale, andretry_intervalstops inventedkidvalues from making the service hammer its provider. The endpoint must behttps, bodies are abandoned pastmax_bytes, redirects are not followed, and a failed refresh leaves the loaded keys in place. (#840) - ✨ A verified token is cached until its own
exporcache_ttlpasses, whichever comes first, so a client that resends one token is answered in about 320 nanoseconds instead of 12 microseconds. The cache keys on a SHA-256 digest computed in the core, so a process holds no live bearer token beyond the request that presented it.cache_key="token"keys on the encoded token and is 94 nanoseconds faster. (#840) - ✨
requiredcovers any claim, not only the ones RFC 7519 registers, sorequired=["exp", "tenant"]refuses a token that carries no tenant. (#840) - ✨
ClientBansrefuses a caller that keeps presenting tokens that do not verify, so a forged-token flood costs a dictionary lookup instead of a signature check. It is off unless you pass one toJWTVerifier(config, bans=...)orJWKSVerifier(config, bans=...), and a verifier built with bans refuses a call that gives it noclientrather than quietly counting nothing. Onlysignature,malformedandalgorithmare counted by default, because countingunknown-keyorexpiredwould ban a service's own users whenever the provider rotates its keys. (#840) - 🔒
requiredonly ever adds to what is enforced. Naming a claim used to replace the expiry requirement rather than extend it, sorequired=["tenant"]read as tightening a policy and in fact turned theexpcheck off, and a token carrying no expiry was then accepted for as long as its key stayed published.expis now enforced whether or not it is named, and a claim written asnullno longer counts as present. (#840) - 🔒
JWTClaims.rawis read-only. A verified claim set is shared by every request presenting that token while it is cached, so writing into it changed what a later request was authorized as. (#840) - 🔒 Naming an
audienceor anissuerrequires that claim. A token that omitsaudno longer satisfies a configured audience, which would have applied the check to the tokens carrying the claim and waved through the ones that did not. Leaveaudienceempty for a provider whose tokens carry none, such as an AWS Cognito access token. (#840)
Internal
- 👷 CI runs only what a change touches. A Python change runs lint, the docs build and the tests. A change under
rust/runs formatting, clippy and the unit tier against the crate built from it. An unchanged crate is installed from PyPI instead of compiled, which cut that step from 53 seconds to under one.rust-toolchain.tomlpins the compiler everywhere the core is built. (#844) - 👷 A release dry run no longer reaches the credentials that publish. Building moved into a job with no environment, and only attesting and publishing stay behind the
pypienvironment, which now accepts a release tag alone and requires an approval. (#842) - 👷
grelmicro-corewheels build for the interpreters pydantic-core ships, with free-threaded builds from 3.14t. The first attempt asked maturin for 3.13t, which it refuses, and produced no wheel on any target. (#843) - 👷 Dependabot proposes updates to the compiled core's Rust dependencies, which include its crypto provider. (#845)
- 👷 Workflows reference this repository's own actions and reusable workflows with the
$/syntax, which resolves to the running commit rather than to files on disk. (#846)
0.41.0 - 2026-09-12
Breaking
- 💥 Every metric attribute grelmicro sets is namespaced.
outcome,resultanddecisionare one word,grelmicro.outcome, andtask.name,check.name,retry.name,circuit_breaker.name,bulkhead.name,timeout.name,rate_limiter.name,idempotency.nameandtopicgain the same prefix.error.typeis unchanged, because the semantic conventions own it. A dashboard or an alert rule reading the old label matches nothing and raises no error, so sweep your queries: in Prometheus the labels are nowgrelmicro_outcomeandgrelmicro_task_name. (#691) - 💥
grelmicro.bulkhead.rejectionsis nowgrelmicro.bulkhead.callswithgrelmicro.outcomeset toadmittedorrejected. A rejection count with no denominator could not give a rejection rate. (#691) - 💥
grelmicro.rate_limiter.decisionsis nowgrelmicro.rate_limiter.calls, and its outcome readsadmittedorrejectedinstead ofallowedorlimited. Every admission primitive now answers one query. (#691) - 💥
grelmicro.retry.attemptscounts attempts. It counted runs, one point per run, whatever the run cost, so a retry storm was invisible. Divide it by the count ofgrelmicro.retry.durationfor the amplification. (#691) - 💥
grelmicro.circuit_breaker.transitionscarriesgrelmicro.circuit_breaker.previous_stateandgrelmicro.circuit_breaker.stateinstead offromandto. The word the gauge uses is now the word the filter uses. (#691) - 💥
grelmicro.task.durationandgrelmicro.retry.durationcarrygrelmicro.outcome, anderror.typeon a failure, so error latency is separable from success latency on one histogram. (#691) - 💥
grelmicro.cache.operations,.stale_serves,.early_refreshesandgrelmicro.shield.cache_writescarry the instance name. Two caches in one process reported as one series. (#691) - 💥 A
cached(ttl=...)decorator applied to more than one function builds one private cache per function instead of one shared between them. Each function gets its ownmaxsizebudget, andcache_info()andcache_clear()answer for that function alone. Pass aTTLCacheto keep sharing one store. (#691) - 💥
CachedResponses(include=...)refuses a pattern written for a path that answers noGET. It would cache nothing while reading as though it did. A prefix pattern is unaffected, because a router holds writes beside its reads. (#813) - 💥
CachedResponses,ConditionalRequests,IdempotentRequests,RateLimitedRequestsandAccessLogrefuse a bad value withSettingsValidationErrorinstead ofTypeErrororValueError, the one error every setting raises, and the middleware under each of them raises it too. A wrong argument type is still aTypeErroron the middleware, where hand-wired ASGI expects one: a set of paths or methods given as a string. (#813) - 💥
CachedResponses(vary_by_query=...)defaults toUNSETrather thanNone.Noneis what the field means by "key on the whole query string", so writing it now says that over any variable, where before it read as an argument nobody passed and the environment answered instead. (#813) - 💥
IdempotentRequests(max_body_size=0)andConditionalRequests(max_body_size=0)are refused. Nothing was held, so every body counted as too large and every request was answered413, which is a mistake rather than a setting.wait_timeout=0is still accepted and still means a duplicate is answered without waiting. (#813) - 💥
AccessLogMiddlewareno longer carriesinclude,exclude,quiet,queryanduser_agentas attributes. They are the component's to decide and move with a live reconfigure, so the middleware reads them from the snapshot it is given, the way every other grelmicro middleware already did. (#813) - 💥
IdempotentRequests(methods=...)andConditionalRequests(require_precondition=...)refuse a bare string.tuple("POST")is four one-letter methods, so the middleware metered nothing while reporting that it did. (#813) - 💥
IdempotentRequests(key_header=...)andreplay_header=refusebytes. A header name is a string, and pydantic decoded the bytes rather than refusing them. (#813) - 💥 The
fastapiextra needs FastAPI 0.137 or later. From that releaseinclude_routerkeeps the router rather than copying its routes into the app, and everything grelmicro reads off a route reads it there. (#744) - 💥
configure()moves uvicorn's own lines fromsys.stderrto the stream the rest of the process writes to, so one process writes one stream and uvicorn's records go through the queuequeue_enabledinstalls instead of being written on the event loop. A shell redirecting2>to keep uvicorn's startup and error lines apart no longer catches them. A handler already pointed somewhere else keeps its stream, aFileHandlerkeeps the file it opened, anduvicorn_enabled=Falseturns the whole pass off. (#737) - 💥 Every grelmicro decorator refuses a function a registrar already holds. A
@retry(attempts=3)under@tasks.everymade one attempt on every scheduled run and three on a direct call, silently. Put the registering decorator on top. (#788) - 💥 To wrap a registered function for a direct call, use
Stack.runorShield.run, or apply the decorator to a function that calls it. (#788) - 💥
IntervalTaskandCronTaskgain a read-onlyfunctionproperty, which is whatTasks.add_taskmarks. ATaskof your own is covered when it exposes one too. (#788) - 💥
TaskRouter(tasks=[...])andTasks(tasks=[...])copy the list they are given, so changing it afterwards no longer changes the router. (#788) - 💥
CircuitBreakerStrategygainsabandon(), the counterpart oftry_acquire(). A custom backend has to implement it: it gives back a half-open probe slot when the admitted call produced no outcome. The four shipped adapters implement it, and the Postgres one does it with a plain statement, so no migration is needed. (#789) - 💥
GrelmicroMiddlewareis imported from the top level:from grelmicro import GrelmicroMiddleware. It is pure ASGI and belongs to no framework, so it no longer ships from one named after FastAPI or Starlette. (#771) - 💥
IdempotencyMiddlewareandStoredResponseare imported fromgrelmicro.http, which is where the wire-facing pieces live. Both are pure ASGI and run on Starlette and Litestar as well as FastAPI. (#771) - 💥 A bounded lock acquire raises
LockTimeoutErrorinstead of a bareTimeoutError. It subclassesWouldBlockError, so oneexceptnow covers a lock you did not get whether the call refused to wait or waited and ran out, and it subclasses the builtin, so anexcept TimeoutErrorkeeps working. It carries the lock and the elapsed wait, and answers a client with the same503a non-blocking refusal does instead of a500. AffectsLock.acquire(timeout=...)andReadWriteLock.acquire(timeout=...). (#772) - 💥
TimeoutraisesDeadlineExceededErrorinstead of a bareTimeoutError. It subclasses the builtin, so anexcept TimeoutErrorkeeps working, and it carriesnameandtimeout, which a bare builtin could not. Catch it to tell a deadline you set apart from a socket timeout raised underneath it. (#740) - 💥
IdempotencyMiddlewareanswers withapplication/problem+jsoninstead of{"detail": ...}. The400,409,413, and422responses all change shape, and a client readingdetailstill finds it, now besidetype,title,status, andinstance. (#740) - 💥 The Postgres circuit breaker drops and recreates three stored functions on the next start, because their result columns grew.
auto_migratehandles it under the advisory lock it already takes. A deployment that runs withauto_migrate=Falsehas to apply the change itself. (#740) - 💥
Lock.do_thread_*and theReadWriteLockmode equivalents take the calling thread's identity instead of its ident. They carry ownership across the event loop for a worker thread and were never the documented way in: uselock.from_thread, which is unchanged and passes the identity for you. (#781) - 💥 Every
Matchargument error is aValueErrorat construction.Match.exception_message()refuses a non-stringcontains=, aregex=that is neither a string nor a compiled pattern, an invalid regex, a pattern compiled from bytes, and the missing-or-bothcontains=/regex=call.Match.predicate()refuses a non-callable. Some of these used to raiseTypeError,re.error, orAttributeError, none of which pydantic converts, so they escaped every documentedexceptin a validator. A non-stringcontains=and a bytes pattern used to build and fail at match time, where the total matcher would now read them as no match forever. (#782) - 💥 A shield's
timeout_errors=refuses aBaseException-only type wherever it appears. Passed alone or by name it was already refused, passed inside a tuple it was accepted and then never retried, because the shield only retriesExceptionsubclasses. (#782) - 💥 A shield's
timeout_errors=takes an exception class, a tuple or list of them, or a fully-qualified name. A set, adeque, or a generator used to slip through and is refused now, because the value is classified before pydantic sees it: the type check pydantic runs reads__class__, which a lazy proxy raises from. (#782) - 💥
GREL_LOG_JSON_SERIALIZERdefaults toauto, which uses orjson when it is installed and the standard library when it is not. Installgrelmicro[standard]and JSON logs get faster with no further setting.autonever writes less than the standard library would, because anything orjson declines falls back to it, but the two do not render every value the same way: orjson writes aUUID, anEnum, and a dataclass natively and writes non-ASCII as UTF-8, where the standard library falls back toreprand escapes. Setstdlibororjsonto pin the exact bytes. (#780) - 💥 A
Bulkheadrefuses two Components claiming one(kind, name)inuses=withComponentAlreadyRegisteredError, where the second used to quietly win. A module-level bulkhead raises at import.Grelmicro(uses=[...])already refused the same list. (#791) - 💥 A
Bulkhead'suses=scope overrides what it lists, where a bare Provider used to override nothing.micro.fake()andmicro.override(...)swap the app's Components and a scope answers first, so neither reaches insideuses=: build the bulkhead with the backends a test wants. (#791) - 💥
IdempotencyMiddlewareandIdempotentRequeststakekey_header=where they tookheader=. Two headers have a name now, so the request one says which it is. (#688) - 💥
health_router()keeps/livez,/readyzand/healthzout of the OpenAPI schema. They answer exactly the same, and the schema names neither the probes nor the shape of the report. Passinclude_in_schema=Trueto publish them. (#690) - 💥
metrics_router()keeps/metricsout of the OpenAPI schema. A scrape target is not part of a client contract. Passinclude_in_schema=Trueto publish it. (#690) - 💥 A published endpoint is documented as what it sends.
/livezand/readyzcarry no content, and/metricscarries the Prometheus text exposition. All three used to be described as JSON, so a generated client tried to parse an empty body or a text one. (#690) - 💥 An
Idempotency-Keyholding a byte outside printable ASCII is refused with400, and the OpenAPI schema publishes the same rule as a pattern. A control byte or a high byte reached the cache as a key and travelled through proxies that may rewrite it, while the schema already said ASCII. (#687) - 💥 Without an identity-aware
key_maker, requests carryingAuthorizationorCookie, authenticated Starlette scopes and FastAPI routes with dependencies bypass the default idempotency key and always run the application. The built-in key moves to av3:format, so entries stored under the old format are not read. (#822) - 💥
IdempotentRequeststakesttl=,namespace=andcache=instead of anIdempotencyinstance, so the common case readsIdempotentRequests(ttl=3600). The component still owns one and exposes it as.idempotencyfor code that needs the store itself. (#687)
Added
- ✨
TTLCache(name=...)names a cache, and every cache metric carries it.micro.cache.ttl()inherits the component's name, so name each cache when an app builds more than one. The caches grelmicro builds for you carry one too:IdempotencyandCachedResponsesuse their registration name, and a private@cached(ttl=...)cache uses the function it serves. It is a metric label rather than a configuration address: a cache still reads no environment variable. (#691) - ✨
grelmicro.task.schedule.delayrecords how late a fire started against the instant it was due, which is queueing on a healthy worker and the full replay age on a fire that came back after a restart. (#691) - ✨
grelmicro.task.next_runis the instant of the next fire, as a Unix timestamp, sogrelmicro_task_next_run_seconds - time()is the time left and a task that stopped shows as a next run stuck in the past. (#691) - ✨
grelmicro.lock.attempts,grelmicro.lock.renewalsandgrelmicro.lock.holdersreport every distributed lock.grelmicro.lock.modetells an exclusive lock, a task lock and a read or write lease apart. A backend nobody can reach counts aserror, a lock another worker holds asunavailable, and a lease gone before the work finished aslost, which is the one to page on. (#691) - ✨
grelmicro.leader_election.attemptsandgrelmicro.leader_election.leadingreport the election. The gauge reads 1 on the leader and 0 on every standby, so a healthy fleet sums to exactly one and a split brain sums to two. (#691) - ✨ Every counter declares the semantic-convention unit for what it counts, such as
{run}or{call}, instead of1. The Prometheus exporter drops the annotation, so no metric name changes. (#691) - ✨ A nested mapping in a config file is written both ways, because the document does not say which it is: as JSON under its own name, for a field that takes a mapping such as
includeorheaders, and walked as a level of nesting as well. Whichever name reaches a field fills it. A key is normalised the way an instance name is, so aLock("cart.v2")is filled by a document that writes the name as it was given. (#813) - ✨ Live reload tunes what a request costs, never what it is protected by.
CachedResponses,RateLimitedRequestsandAccessLogare fully live.ConditionalRequestsandIdempotentRequestsare configured at startup, every field of them, because every one of theirs protects something: taking a path out of either one, turning off payload fingerprinting, or lowering the body limit each removes a guarantee nobody is told about. A key a file may not change is reported and the rest of the file still applies. (#813) - ✨
RateLimitedRequests()documents the429and theRetry-Afterit carries on every operation in the OpenAPI schema, and theRateLimitandRateLimit-Policyfields on every success beside it, because a client reads what it has left off the answer it got rather than off the refusal it did not, so a generated client has a branch for it. Every operation rather than the metered ones, because what is metered is tuned live and the schema is not. Passopenapi=Falseto leave the schema alone. (#813) - ✨ The OpenAPI schema is built once, from the app as installed, and nothing it states is live. A published contract is not a tuning knob, and each replica polls its own source on its own clock, so a live schema would have two pods publishing two different documents. (#813)
- ✨ Every HTTP component is tuned live from a mounted ConfigMap.
GREL_CACHED_RESPONSES_TTL,GREL_ACCESS_LOG_EXCLUDEand the rest reach the running middleware without a restart, because it reads a snapshot the component publishes rather than the values it was built with. (#813) - ✨
CachedResponsesConfig,ConditionalRequestsConfig,IdempotentRequestsConfig,RateLimitedRequestsConfigandAccessLogConfig, each with afrom_config()classmethod, for a configuration assembled from a settings tree. (#813) - ✨
RateLimitedRequests(include=...)meters only the paths it names, so all five HTTP components now take the same two selectors. (#813) - ✨
CachedResponses(include=("/products/*",))takes a tuple as well as a mapping, and keeps each path for the component's ownttl. (#813) - ✨
python -m grelmicro check --appwarns about anincludepattern that matches no route. A mistyped one turns a rule off without saying so, and nothing matching is the only trace it leaves. (#813) - 🔒
CachedResponses(include=...)names a path that is never cached when the app refuses it, whichever pattern named it: the store itself answersNonefor a read behind a security scheme, so no way of writing a pattern can put one in the cache. It is also refused where it is written. A pattern is matched against the URL a request asks for, and a route template stands for many, soinclude={"/users/me": 30}onGET /users/{uid}named no template, walked past the refusal, and cached a gated read: the second caller was handed the first caller's response, and a request carrying no credential was answered200with it. (#813) - ✨ A row says
(some paths)where a pattern names one URL of a route that serves many, such asexclude=("/users/me",)onGET /users/{uid}. Saying the rule applies to the endpoint would overstate it and saying it does not would be wrong. (#813) - ✨
micro.describe(app).endpointssays what every registered component does to each route, so "what happens toGET /products" is one line instead of five registrations.python -m grelmicro check app:micro --app app:appprints it. (#813) - ✨ A mounted file decodes a field holding many values the way the environment does, so one ConfigMap says the same thing whether it is mounted as a volume or read through
envFrom. A field whose value isOptionalwas skipped before, which dropped every other key in the same file with it. (#813) - ✨ A malformed value on a field holding many values says that JSON is what it takes, instead of naming the source that failed to parse it. (#813)
- ✨
RateLimitedRequests(limiter, trusted=...)turns a caller over its budget away at the edge, so a service stops writing the429by hand. A request spends one token of every limiter listed, so a burst limit stands beside a daily one. (#739) - ✨ Allowed or refused, the response states what the caller has left in the
RateLimitandRateLimit-Policyfields of the RateLimit header specification, and a refusal addsRetry-Afterand travels the sameAdmissionErrorpath every other rejection takes.legacy_headers=Trueadds the supersededX-RateLimit-*fields. (#739) - ✨ The bucket is the client address a trusted proxy vouched for, resolved once per request and shared with everything downstream. A limiter keyed on the socket peer meters the ingress rather than the caller, so
trusted=or akey=of your own is required. (#739) - ✨
RateLimited(limiter, cost=...)meters one FastAPI route on top of whatever the app meters, for an endpoint that costs more than the rest. (#739) - ✨ A rejection carrying
headersputs them on the answer, so what a refusal has to tell the client travels with the status. Starlette, FastAPI and Litestar all merge them. (#739) - ✨
CachedResponses()answers a repeated read from the cache instead of the handler. Declaredependencies=[CachedResponse(ttl=60)]on a route, or name the URLs withinclude={"/products/*": 60}on any ASGI framework. It rides the registeredCache, so a response one replica computed answers the callers of every other one, and one cold key runs the handler once rather than once per caller. (#744) - ✨ A stored response carries an
ETagand anAge, and a client that already holds the tag is answered304 Not Modifiedwith no body. (#744) - ✨ A response is never stored when it was answered for one caller: a request carrying
AuthorizationorCookie, a response carryingSet-Cookieor aCache-Controlrefusing it, anything but a200, and aVarynaming a header outsidevary_by_headers. None of those is configurable, because each one is the same mistake. (#744) - ✨
CachedResponses.purge()drops every response it stored, for a write that changes what a read answers. (#744) - ✨
dict_config()returns a logging configuration the application server applies itself, so the process reads in one format from its first record. Uvicorn takes it aslog_config, Gunicorn and Hypercorn aslogconfig_dict, Granian aslog_dictconfig. It covers the two casesconfigure()cannot reach: the parent process behind--reloadand--workers, which never imports your application, and a script that callsconfigure()and thenuvicorn.run(app). (#737) - ✨
dict_config(env_load=...),formatter(env_load=...),handler(env_load=...)and both uvicorn formatters readGREL_LOG_*from a process that cannot setGREL_ENV_LOAD, the same wordconfigure()takes. (#737) - ✨
dict_config_with(config)builds the same document from a pre-builtLogConfig, for settings that never reach the environment. Both documents are a snapshot of the settings they were built from, and JSON-serializable, so one is written to the fileuvicorn --log-configreads. (#737) - ✨
formatter()andhandler()return the pieces the document is made of, for naming from adictConfigof your own.handler()writes to the stream the rest of the process writes to, and starts the queue writer when the settings ask for one and none is running. (#737) - ✨
OpsServerserves the health probes and the Prometheus endpoint on a port of its own, over a small HTTP/1.1 server from the standard library. A FastStream consumer, a scheduler, or any process that runs no web framework answers Kubernetes withGrelmicro(uses=[health, OpsServer(port=8080)]). (#690) - ✨
health_asgi()andmetrics_asgi()serve the same endpoints as pure-ASGI apps, so Starlette and Litestar mount them instead of hand-writing routes. On FastAPI,health_router()andmetrics_router()stay the door, because they add the OpenAPI schema and theDependsgates. (#690) - ✨
Grelmicro.openedsays whether every registered item is open. Read it from something that answers before the app has finished starting: a component registered before another one runs while that other one is still connecting. (#690) - ✨ Every door renders through one set of functions, and
tests/test_endpoint_parity.pyholds them to one status, one set of headers, and one body. (#690) - ✨ A middleware grelmicro installs says whether it answers a request or only watches one. A watcher wraps outside the middleware the app added itself, so an access log records the request an outer layer refused, with the status it was refused with. One that may answer, an idempotent replay above all, stays innermost where it can never be the reason a request skipped authentication. (#742)
- ✨
AccessLog()writes one structured record per HTTP request, carrying the callergrelmicro.securityresolved rather than the socket peer, the route template, the duration, and the trace context, under OpenTelemetry semantic convention field names. The level follows the answer:5xxan error,4xxa warning, and a probe that answered a debug, so Kubernetes polling every few seconds stops crowding out the requests a person wanted to read. Registering it silences uvicorn's own access log. (#742) - ✨
configure(queue_enabled=True)hands log writes to a background thread, so a slow sink stops showing up as request latency. The record is still rendered on the calling thread, so the trace context and every bound field survive. One writer serves all three backends. (#743) - ✨ The log queue is off by default, because it earns its keep only on a slow sink. Against a no-op write it costs about 0.9 µs per record. A burst of 5000 records against a sink taking 200 µs a line fits in the queue and takes the caller from 276 µs to under 8 µs a record, with all 5000 written. Sustained load past what the sink can drain drops about a quarter of them instead, reported as it goes, which is the trade the bound buys. Run
python benchmarks/log_queue_benchmark.pyfor the table. (#743) - ✨
GREL_LOG_QUEUE_SIZEsets how many rendered records the queue holds, 10000 by default. A full queue drops the arriving record rather than blocking the caller, and reports the count as oneERRORongrelmicro.logonce it has room again. (#743) - ✨ The queue is drained on shutdown, on reconfiguration, and across a fork. A forked child gets a worker of its own rather than inheriting a queue nothing reads, and a
multiprocessingchild drains before it exits, whereos._exitskips the handler every other path relies on. Uvicorn's own handlers are pointed at the same queue, so its access lines go through it too. (#743) - ✨
PostgresProvider.from_engine(engine)borrows a SQLAlchemyAsyncEngineinstead of opening a second pool. Every operation takes a connection from the engine and unwraps it to asyncpg, so the statements run unchanged and the database sees one pool. The engine has to bepostgresql+asyncpg, and anAsyncSessionor anAsyncConnectionis refused: those carry the transaction the caller has open, and a lock released inside it would come back locked on a rollback. A borrowed connection is cleaned before it goes back, so an open transaction or aLISTENof grelmicro's is never found by the application's next query, and one that cannot be cleaned is dropped rather than pooled. The application's own session settings are left alone, because the engine applies those once per connection and never again. Nothing is sent when there is nothing to undo, so a statement run through the provider costs one round trip, not two. (#685) - ✨
EventLoopDeadlockErrorsays a sync entry point was reached from the event loop that has to serve it. ABaseException, soexcept Exception, aRetry, and aFallbackall pass it through: a wiring mistake no retry can fix reads as a failure, never as a fallback value. It is the one grelmicro error that is not anException. (#787) - ✨
Stack("recs", patterns=[...])applies resilience patterns in the safe order,Fallback → Retry → CircuitBreaker → RateLimiter → Bulkhead → Timeout → call, whatever order you list them in. It takes built patterns and adds no configuration of its own, decorates and offersrun(fn, ...), skips aNoneentry the wayGrelmicro(uses=[...])does, and refuses two of one pattern or a stack of none. (#689) - ✨ Inside a
Stack, a refusal by its own patterns is read as a call that never happened: itsCircuitBreakerErroris never retried by itsRetry, and itsRateLimitExceededErrorandBulkheadFullErrorare recorded as no outcome by itsCircuitBreaker. Every other error reaches each pattern exactly as its ownwhen=orignore_exceptionsdecides. (#689) - ✨
RateLimiterdecorates.@limitermeters a function under thedefaultbucket, and@limiter(key="user:{user_id}")meters per argument with the same key vocabulary@cacheduses, pluskey_maker=,cost=, andmax_wait=for a bounded wait instead of an immediate refusal. (#689) - ✨ A
@limiterapplied under a task decorator is refused, for the same reason aStackis: the schedule holds the function it was handed, so a limiter below it would meter direct calls and leave every scheduled run unmetered. (#689) - ✨ A
Stackapplied under a task decorator is refused. A task decorator registers the function it is handed and returns it unchanged, so aStackbelow it wrapped direct calls only and left every scheduled run unprotected, silently. Put the task decorator on top. (#689) - ✨ Export
Pattern, the type of everythingStack(patterns=[...])accepts, andRateLimiterBinding, whatlimiter(key=...)returns. (#689) - 📝 Composing patterns says which name a pattern takes:
CircuitBreakerandRateLimiterare named for the external system and shared between call sites, whileRetry,Timeout, andFallbackare named for the call. One system usually has several stacks that share one breaker instance. (#689) - ✨ Register
ErrorResponses()and every rejection grelmicro raises answers the client as an RFC 9457 problem detail.micro.install(app)wires the handler on FastAPI, Starlette, and Litestar, so a rate limiter over budget, a full bulkhead, an open circuit breaker, an elapsed deadline, or an idempotency conflict returnsapplication/problem+jsoninstead of becoming a500. Leave the component out and grelmicro changes nothing about how your framework answers an error. (#740) - ✨
grelmicro.httpshipsProblemDetailandTMFError, the Pydantic models for each body,RenderedErrorfor what an integration writes,send_error(send, rendered)to write one from a pure-ASGI middleware, andmerge_headersfor an integration building a framework response. (#740) - ✨
CircuitBreakerError.retry_afterreports the seconds until the breaker next admits a probe, counted on the backend that holds the state so every replica agrees. It reaches a client asRetry-After. A breaker an operator forced open reports0.0, because nothing but a reset releases it. (#740) - ✨
CircuitBreakerSnapshot.retry_aftercarries the same value from every adapter, memory, Redis, Postgres, and SQLite alike. (#740) - ✨
document_idempotency(app)publishes aProblemDetailcomponent and points each middleware response at it, so a generated client knows the body it will get. (#740) - ✨
ErrorResponses.tmf()renders rejections in the TM Forum error format of TMF630, for a service answering to a telco or OSS platform built on TM Forum Open APIs. The status codes are unchanged: TMF630 mandates the IANA registry and names422,429and503itself, so nothing is remapped andRetry-Afterkeeps its meaning.code_prefix=namespaces the mandatorycode, andreference_error=points the documentation URI at your own docs or leaves it out. (#773) - ✨
document_idempotency(app)publishes whichever format is registered, so a generated client decodesTMFErrorrather thanProblemDetailwhenErrorResponses.tmf()is wired. (#773) - ✨ The framework's own errors answer in the registered format too, so the whole API answers in one shape. An
HTTPExceptionkeeps its status, its message and any header it carried, and a request that failed validation becomes avalidation-failedresponse carrying the field errors. The validation status stays the framework's,422on FastAPI and400on Litestar. Register a handler of your own for either and grelmicro leaves it alone. (#773) - ✨
error_response(request, status=...)builds a response in the app's registered format from your own exception handler, on Starlette, FastAPI and Litestar. (#773) - ✨ The generated OpenAPI follows the registered format on FastAPI and Litestar, so a client built from the schema decodes what the app actually sends. The models the framework published for its own error shape are dropped once nothing points at them. A response you declared yourself keeps its schema. (#773)
- ✨
grelmicro.integrations.starletteis the home of the pure-ASGI wiring: the lifespan, the per-request binding, the error responses, anderror_response. A Starlette app no longer imports from a module named after another framework. (#775) - ✨
ConditionalRequests()answers conditional requests, so a write cannot erase a change it never saw.micro.install(app)puts anETagon responses and answers304to a read that already holds one, andcheck_precondition(etag_of(cart.version))refuses a stale write with412and an unconditional one with428. Works on FastAPI, Starlette, and Litestar. (#771) - 🐛 A
4xxor5xxis never answered as304. A read that recorded a version and then failed, because the row was deleted or the caller was refused, would have told a client its cached copy was still good. (#771) - 🐛 A handler that sets its own
ETaggets the304shortcut, which used to be the one case that lost it. (#771) - 🐛 A background task no longer delays the response. The middleware decides when the body completes rather than when the app returns, and a framework runs background work inside that call. (#771)
- 🐛 On Litestar, the middleware lands under every layer the app built, not only the first. With CORS or a plugin configured, one hop left it above the layer that renders exceptions, and the framework's
500was stored and replayed. (#771) - 🐛
include=("/payments/*",)selects/paymentsas well as what sits under it, so naming a router's prefix covers the create route on the router itself. (#687) - 🐛 A second
micro.install(app)wires nothing twice. It used to add a second binding on Starlette and FastAPI, and a second copy of every registered middleware on Litestar, which stored, captured and answered twice. (#687) - 🐛 A streamed response carries the entity tag a handler recorded with
check_freshness, and is still swapped for a304when the client already holds it. The tag is known before the first byte, so neither needs the body. (#771) - 🐛 A
304a handler answers itself carries the entity tag it recorded withcheck_freshness, which RFC 9110 asks for. (#771) - 🐛
install_middlewarerefuses an app that already built its middleware stack, the wayadd_middlewaredoes, rather than editing a list nothing reads any more. (#687) - 🐛 A streamed response is never held back.
ConditionalRequestsMiddlewarebuffers a body only while it arrives in one piece, so an event stream reaches the client as it is produced rather than as one message at the end. A streamed response carries no generated entity tag, and neither does a204, whose empty body would otherwise hash to one tag for every empty resource. (#771) - 🐛 On Litestar,
IdempotencyMiddlewareruns under what turns an exception into a response, so an unhandled error is no longer stored and replayed as a sticky500for the whole window. It now matches Starlette and FastAPI, where the exception never reaches the middleware. (#771) - 🐛
etag_ofrefuses a version holding a comma, which separates two entity tags in one header and would have made everyIf-Matchmiss forever. (#771) - 🐛 A middleware the app added by hand is not added a second time by
install, on Starlette and FastAPI as it already was on Litestar. (#687) - 🔒
etag_ofand the idempotencykey_makercheck never let a caller's object raise out of them. A lazy proxy raises from__class__, a metaclass can raise from__name__, and amodel_dumpproperty runs caller code, so every shape test and every name goes through the same guards the matchers use. A rejected value is named by its type and never printed. (#771) - 🔒
micro.install(app)places grelmicro's middleware innermost, behind every middleware the app added itself. A middleware that answers a request on its own, such as an idempotent replay, can no longer answer before the app's authentication runs. Registering the component after the app's own middleware, which is the natural order, used to put it in front. (#771) - 🔒 A new
middleware-placementdiagnostic warns on Litestar, which builds its middleware stack at construction soinstallcan only wrap the whole app. It names the fix, and a middleware the app already wired at construction is left alone rather than wrapped twice. (#771) - ✨
ConditionalRequireddeclares the precondition on the route, so the schema marksIf-Matchrequired exactly where the service refuses without it, and a missing one answers428rather than a validation error. (#771) - ✨
Conditionalinjects the same guards on FastAPI, so a handler writesconditional.check(cart.version)with the dependency the framework expects. One implementation underneath, and the imported functions stay the portable form. (#771) - ✨ Every HTTP middleware selects paths the same way:
include=names what it acts on, empty meaning everything, andexclude=carves out, winning overinclude. A pattern ending with*matches as a prefix, soinclude=("/payments/*",)selects a router by its prefix. The OpenAPI annotations follow the same selection. (#687) - ✨
check_freshness(version)puts theETagon a read and returns whether the client already holds that version, so a handler needs noResponseobject and can skip the work behind a304. (#771) - ✨ A registered HTTP component answers its own rejections.
ConditionalRequests()alone returns412and428instead of a500, andIdempotentRequests()does the same for a conflict raised by the block form.ErrorResponses()chooses the format they render in, RFC 9457 by default. (#771) - ✨
micro.install(app)describes the conditional headers in the OpenAPI schema, so Swagger offersIf-Matchon writes andIf-None-Matchon reads, with the304,412and428responses beside them. The header is marked required exactly whenrequire_preconditionrefuses without it. (#771) - ✨
etag_of(value)builds an entity tag from a version token (int,str,UUID,datetime) or by hashing a representation (bytes, JSON data, a pydantic model) with SHA-256. The serialization is canonical and never the JSON library that happens to be installed, so every replica issues the same tag for the same value. (#771) - ✨
PreconditionFailedErrorandPreconditionRequiredErrorjoin the rendered rejections as412and428. Raise the first yourself when a conditionalUPDATEchanges no row, and the client reads the same answer as a staleIf-Match. (#771) - 📝 A Conditional Requests guide, including the three ways to back the write in a database: a conditional
UPDATEon a version column,SELECT ... FOR UPDATE, or a distributedReadWriteLock, with the first named as the one to reach for. (#771) - ✨
IdempotentRequests()inuses=[...]hasmicro.install(app)addIdempotencyMiddlewareand describe it in the OpenAPI schema, so the container holds the whole wiring. It takes every middleware option, andopenapi=Falseleaves the schema alone. (#687) - ✨ An integration may carry
install_middleware(app, components), and a component may carryasgi_middleware().installfeature-detects both, so a component that asks for a middleware is wired on every framework that takes one and skipped on a framework that serves no HTTP. (#687) - ✨ Every pattern works on every framework, and a test holds it.
tests/test_framework_parity.pywalks the package for ambient resolution, refuses an empty scan, and runs each pattern inside a FastAPI, Starlette, and Litestar handler and inside a FastStream subscriber. A rejection and an idempotent replay are compared byte for byte across the three HTTP frameworks. (#771) - 📝 The Frameworks page states what is portable and names the three pieces that are not:
health_router(),metrics_router(), anddocument_idempotency(app), all of which need something only FastAPI builds. (#771) - 📝 An Error Responses guide and an HTTP reference page. Each
typeURI dereferences to its own section. (#740) - 📝 A Performance page: the standard extra, serializer choice, orjson logging, stampede modes,
backend=on hot paths, andinstall(ambient=False). (#780) - ✨
replay_header=names the response header that marks a replay, for clients that read another one. No standard names this header, andIdempotent-Replayedstays the default. (#688) - ✨ A header name that is not a string, or not an HTTP field name, raises
SettingsValidationErrorwhen the component is built, instead of reaching the wire as a broken header.replay_headeralso refuses a name that directs the client, such asContent-TypeorContent-Length, andkey_headerrefuses a name every request already carries, such asAuthorization, which would key one stored response for every caller that shares the value. (#688) - ✨ The replay marker means a replay and nothing else. A handler that sets the replay header itself loses it, on a fresh response as well as a replayed one, and the loss is logged once, so a client reading the header's presence is never told a fresh response is a replay. A marker from a second
IdempotencyMiddlewarerunning under the first is the one exception, and it travels. (#688) - ✨ The OpenAPI schema describes the replay header on the responses of every covered operation, beside the key header it already described. The name is a service's to pick, so a generated client reads it there. It is published as a header that may appear, because which responses carry it is decided by the middleware order at runtime. (#801)
- 📝 Idempotency Middleware says which of the two headers is standard. The draft registers
Idempotency-Keyand names no response header, so the replay header is common practice rather than a specification. (#688)
Fixed
- 🐛 A query parameter that addresses a row rather than holding a secret keeps its value in a log.
sort_key,partition_key,routing_key,cache_keyandshard_keyread as credentials to the*_keyrule and never carry one, in any spelling:sortKey,sort-keyandsort.keyall read the same. Every other name ending inkeyis still masked,idempotency_keyincluded, because a leaked one can replay a request. (#833) - 🐛 A leaked loguru patcher no longer survives a reconfigure. Moving from a format that needs one, such as
{extra[serialized]}, to a format that does not left the old patcher serializing every record, because loguru replaces a patcher only when it is handed one. (#833) - 🐛 The sync
@cacheddeadlock message names a callable object instead of raisingAttributeErrorwhile building itself. Reading__qualname__off an object that has none lost the one line that says what to do about the deadlock. (#821) - 🐛
@retryengages on an object whose__call__is async. It reported itself as applied and did nothing: the wrapper took the coroutine the object returned as the result, so the body ran once and the caller saw the first failure, with no retry, no backoff and no budget. (#820) - 🐛
@fallbackanswers for an object whose__call__is async. Itsexceptran before the body did, so the failure reached the caller and the fallback never engaged. (#820) - 🐛
@timeout,@bulkhead,@shieldand@cachedaccept an object whose__call__is async, instead of refusing it as sync code. A genuinely sync callable is still refused. (#820) - 🐛
CircuitBreakeradmits an async callable object on the event loop instead of raising that it cannot be used from there, and@measureand@instrumenttime the body rather than the construction of its coroutine. A measured body that raised was never recorded as an error. (#820) - 🐛
@measureno longer raises from inside the call it watches. An instrument name has to start with a letter and carry no brackets, so a function defined in the entry-point script (__main__.charge) or a lambda (<lambda>) made the OpenTelemetry SDK raise on the first call, and only once metrics were turned on. An app that ran in development failed in production. A name the specification already accepts is untouched, so no metric is renamed. (#691) - 🐛 A
functools.partialor a callable object no longer puts a memory address in a name grelmicro derives from it. Areprcarries the address of the object, so the name changed on every restart of the process. It reached the@measuremetric name, the@cachedcache key and its private cache name, the@shieldname, and the@instrumentspan name. Each now names the function behind the partial, and falls back to the type's qualified name for a callable object. (#691) - 🐛 The default
@cachedkey for afunctools.partialwas unreachable after a restart and from every other replica, because the address in it changed each run. A cache with no explicitkey=served a permanent miss and grew its keyspace forever. Keys for a named function are unchanged. (#691) - 🐛 A
@shieldon a partial produced aGREL_SHIELD_*environment prefix carrying that address, so the variable an operator had to set could not be predicted, let alone bound. (#691) - 🐛 Four
typealiases named something imported only for a type checker, so reading the alias raisedNameError. A lazily-evaluated alias passes every import and every checker and then fails wherever pydantic,typing.get_type_hintsorfast_dependsreads it.tests/test_public_api.pynow evaluates every alias in the package. (#691) - 🐛
ConditionalRequiredon a route of an included router documentsIf-Matchas required again. FastAPI 0.137 stopped copying those routes into the app, so nothing found them. (#744) - 🐛
GREL_LOG_LEVELgoverns the application server's own lines. Gunicorn and Hypercorn pin a level on their loggers before they apply a log config, so the process answered to two thresholds: one for the application and the server's own for the server. Uvicorn sets its level afterwards, so--log-levelstill wins where it is passed. (#737) - 🐛 The level is colored in the TEXT format. It was padded to eight characters before being looked up in the color table, so it never matched and only the time and the logger were colored. (#737)
- 🐛
UvicornFormatterandUvicornAccessFormatteracceptuse_colors. Uvicorn writes it into a log config it was handed as a dict, asuvicorn.run(app, log_config=...)does, when it is started with--use-colorsor--no-use-colors. The formatters raisedValueError: Unable to configure formatterinstead. A log config read from a file never carried the value, so it was never affected. The flag turns colors on and off for TEXT and PRETTY, and never colorizes JSON or logfmt. (#737) - 🐛
callerfollowscaller_enabledon uvicorn's own records, like every other logger's.UvicornFormatterforced it off, so the two ways of rendering a uvicorn record disagreed. (#737) - 🐛 A handler on the log queue follows when the writer is replaced or taken out, whether or not the uvicorn takeover is on.
Logstopped the writer on exit and gave the root logger back, and aconfigure(uvicorn_enabled=False)after a document replaced it, and in both cases uvicorn's handlers kept the stopped one and wrote inline. (#737) - 🐛
asctimeandcolor_messageno longer reach the sink. Uvicorn attaches an ANSI-colored copy of its message undercolor_message, andasctimeis a time alogging.Formatteralready rendered, which every record carries astime. Both rendered as an extra field. (#737) - 🐛
include=,exclude=andquiet=refuse a bare string.exclude="/internal/*"is a sequence of characters, so it was walked one at a time, and the trailing*matched every path as a prefix: the middleware then acted on nothing at all, silently. AffectsAccessLog,IdempotentRequests,ConditionalRequests, and the three middlewares behind them. (#742) - 🐛
Log(uvicorn_enabled=...)is accepted.LogConfigcarried the knob andconfigure()took it, but the component did not, so the only way to set it throughLogwasfrom_config. (#743) - 🐛 A record written through the standard library reaches the format the app configured on every backend.
loguruandstructloginstalled no root handler, so grelmicro's own component records, and every dependency logging throughlogging, fell through tologging.lastResort: the bare message on stderr at warning and above, and nothing at all below it. Those two backends now take the root logger the way thestdlibone always has, so a handler your code attached to the root before configuring is replaced rather than kept: attach it after, or on a logger of your own. (#806) - 🐛 A loguru format template is read as the format it renders, by every writer in the process.
"{extra[serialized]}"is JSON and"{extra[logfmt_serialized]}"is logfmt, wherever the record comes from. Any other template is loguru's alone, so records from the standard library and from uvicorn are written in grelmicro's text format:UvicornFormatterrendered those as JSON before, whatever the template said. (#806) - 🐛
/metricsanswersCache-Control: no-store, as the health endpoints already did. A caching proxy could serve a stale exposition to a scrape. (#690) - 🐛
document_idempotency(app)describes every installedIdempotencyMiddleware, each with its own paths and its own two header names. An app running a second set of rules had one of them published. Passidempotency=to describe one alone, which is whatopenapi=Falseon a component now rests on. (#801) - 🐛
include=andexclude=match the route, not the prefix a mount or a proxy adds. A pattern is now the same in an app served at the root, mounted under another app, or behind aroot_path, and the same as the path the OpenAPI schema publishes. Only a whole segment is a prefix, so aroot_pathof/apileaves/apikeysalone. AffectsIdempotencyMiddlewareandConditionalRequestsMiddleware. (#801) - 🐛 The OpenAPI annotation for idempotency follows
include=andexclude=. An operation the middleware passes through was described with the idempotency header and the responses only a covered operation can get. (#801) - 🐛 A half-open probe that ends without a call outcome gives its slot back instead of holding it. The slot is taken on admission and returned by recording the outcome, so an exit with anything that is not an
Exception, anasyncio.CancelledErrorabove all, kept it forever: the circuit then refused every call for a day while the dependency was healthy. A cancelled probe, a shutdown mid-probe, and an outer deadline all did it. (#789) - 🐛 A
Bulkheadresolvesuses=almost the wayGrelmicro(uses=[...])does, soBulkhead("reports", uses=[redis])scopes what redis serves instead of overriding nothing. A bare backend becomes its Component, a bare Provider fills a default Component per kind, two bare Providers raiseAmbiguousProviderError, and a Provider or bare backend this app run already opened is never opened again. One thing listed twice is one item, where the app refuses the list. (#791) - 🐛 A
Bulkheadrefuses auses=entry that is not an async context manager, with aTypeErrornaming the bulkhead and the type it got. It used to construct and fail on the first entry, with aTypeErrorthat named the type but not the bulkhead. (#791) - 🐛 A
Bulkheadopens itsuses=scope once per app run, not once per process. The record lived on the bulkhead and was never released, so a secondGrelmicroapp in the same process entered nothing and ran every call against providers closed with the first, and an entry that had failed part way left the next app resuming at the item that failed. Entering a scope whose app run has gone now raises instead of using closed providers, and only the run that owns a scope enters its items, so a run overlapping it borrows them rather than opening a second set. (#791) - 🐛 A
Bulkheadgives its permit back when the scope it opens on first entry fails, and the retry resumes where that entry stopped. Auses=provider that was briefly down took a permit and never returned it, so a bulkhead saturated itself one failed entry at a time, and the next attempt entered every item it had already opened a second time. (#689) - 🐛 Every sync entry point refuses a call made from the event loop its backend runs on instead of deadlocking on it.
lock.from_thread, bothrwlockviews,task_lock.from_thread,breaker.from_thread, and the sync@cachedwrapper hand their work to that loop and block on the answer, which never arrives when the caller is that loop: the process simply stopped making progress, with nothing logged. Each one now raisesEventLoopDeadlockErrorand names the way in. A caller on a different loop, or on a thread with no loop at all, is unaffected. (#787) - 🐛 A
Matchpredicate that returns a non-bool is warned about once per predicate, not once per address, so a collected predicate no longer silences the next one. (#782) - 🐛 Calling a matcher never raises. A predicate that raises, a class with a hostile
__instancecheck__, an exception whose__cause__raises, and a return whose truth value cannot be read each read as no match, and each is reported once per predicate. An exception whose__str__raises has no message to match, so a message matcher answers no match without a warning.Retrycalls a matcher from anexceptblock, so an error there replaced the very exception being handled. (#782) - 🐛 A config value that cannot be classified is refused with the error the component documents.
Retry(when=...),Fallback(when=...)and a shield'stimeout_errors=all tested the shape withisinstance, which reads__class__, so a lazy proxy such as an unboundwerkzeug.local.LocalProxyraised from inside the validator. OnlyValueErroris converted, so what it raised escapedexcept SettingsValidationErrorentirely. (#782) - 🐛 A container passed to
when=or to a shield'stimeout_errors=is walked once, and a container that refuses to be walked is an argument error.__iter__is caller code, and a lazily-populated container raises from it once it is detached from what filled it. (#782) - 🐛
Match | otherandMatch & otherraiseTypeErrorwhen the other side is not aMatch, instead of building a combination that fails later.TypeErrorhere andValueErroreverywhere else is Python's operator protocol: the operators returnNotImplementedand the interpreter raises, which is what lets the other side answer with__ror__if it can. (#782) - 🐛 Reporting a broken predicate never raises either. A logging filter is caller code and
Logger.handleruns filters unguarded, so a request-context filter raising outside a request escaped from the warning itself. (#782) - 🐛 A warning names a predicate, it never prints one. A
functools.partialpredicate carries the arguments bound into it and an object predicate carries its attributes, so an API key held by either used to reach the log. A literal still reads as itself. (#782) - 🐛 A
Match.not_*matcher reports a broken predicate under the name the caller wrote. It delegates to its positive twin, and the twin is what catches the predicate, sonot_exception(check)saidexception(check). (#782) - 🐛 A
functools.partialpredicate reads as the function it wraps, so two partials in one policy are tellable apart. The arguments bound into it, where an API key would sit, stay out of the label. (#782) - 🐛
Match.predicate()coerces its return the wayMatch.exception()andMatch.result()do, so a predicate returning1engages the strategy and is warned about once instead of returning1to it. (#782) - 🐛 A lock token identifies the holder, not the thread ident or the object id it sat at. CPython hands both to the next holder, so a thread or task that died holding a lease left a token the next one reproduced exactly and could release, extend, or take a lock it never acquired. (#781)
- 🐛 A log record carrying a non-string dict key is written instead of raising.
extra={"counts": {1: "a"}}raisedTypeErrorunder orjson where the standard library wrote it, so which one was installed decided whether the log call survived. (#780)
Security
- 🔒 An idempotency key can no longer replay a private response across callers when no identity-aware
key_makeris configured. Requests carryingAuthorizationorCookie, authenticated Starlette scopes, and FastAPI routes with dependencies, including ordinary custom-header authentication, bypass the unscoped default and always run the application, so route-level authentication cannot be skipped by a replay. Discovery covers FastAPI wrapped directly, behind route-transparent ASGI middleware, through a directly wrapped mount or router endpoint, or mounted under another ASGI application while keeping parent dependencies outside a mounted public app. Starlette authentication configured lazily on an app, on an individual route, inside an included router, or inside a mount is detected path by path, so a protected mount bypasses the default without disabling public siblings. FastAPI dependency overrides are followed through included and nested routers, including an override added, replaced, or removed after traffic starts; an outer include provider remains authoritative over conflicting child application providers, matching FastAPI's routing context. Response-cache declarations and explicit parent cache rules stop at middleware wrapped around or configured inside a mounted application, and at route-local middleware, so a parent hit cannot skip those boundaries. A required idempotency key is still enforced before that bypass. The built-in key includes canonical authority, scheme, and root path and uses structured field encoding, so host-routed tenants, mounts, and fields containing delimiters cannot cross-replay. The serialization is stored as a fixed 67-bytev3:SHA-256 key, so large incompressible queries remain safe for indexed PostgreSQL text columns. Its version is bumped so an upgraded or rolling-deployment process cannot read entries written by either vulnerable format; customkey_makervalues remain unchanged. - 🔒 Access logs retain query parameter names but mask every value. OAuth/OIDC tokens, authorization codes, presigned URL signatures, cloud credentials, and application-specific bearer capabilities no longer depend on an incomplete credential-name denylist to stay out of logs. URL and configuration redaction also recognizes qualified and camel-case password, key, token, credential, secret, and signature names, masks credential parameters anywhere in fragments, including path-prefixed assignments and nested redirects separated by raw or percent-encoded question marks, and fully masks ambiguous scheme-less or malformed-scheme credentials containing colons, commas, or extra
@characters. - 🔒
SettingsValidationErroris raised even when the rejected value resists inspection. Deciding what to redact ranisinstanceand rendered the value, both caller code, so a lazy proxy raising from__class__replaced the error with its own and skipped the redaction that keeps a credential out of the message. Every step of the scrub is total now. (#782) - 🔒 A problem detail never carries the exception message. Those messages name the thing that was refused, and
RateLimitExceededErroralone would have published the key it rejected, which is often a client address or a user id. grelmicro writes everydetailitself. (#740) - 🔒 A problem detail serializes through pydantic's JSON mode, so an extension member that is not a JSON native cannot fail the response. Whether the encoder coped with a
UUIDor aDecimalotherwise depended on which JSON library was installed, which is a page that works in development and fails in production. (#740) - 🔒 Every error response carries
Cache-Control: no-store, so a shared cache cannot serve one client's429to another, andX-Content-Type-Options: nosniff, because the body reflects the request path back ininstance. (#740)
Performance
- ⚡
Stack.runbuilds nothing per call. It rebuilt the whole pattern chain on every invocation, and now travels one chain built with the stack, carrying the target as an argument. A two-pattern stack drops from about 6.3 to about 3.5 microseconds per call, and a six-pattern one from about 15.5 to about 11.2, which puts the imperative form within about a microsecond of the decorator. Measure it withpython benchmarks/stack_benchmark.py. (#792) - ⚡ Ambient backend resolution costs about 2.7x less. A pattern that omits
backend=ran three imports and two frames of lookup on every operation, and now runs one function and one dict lookup.Lockdrops from ~270 ns to ~100 ns per resolution, andTTLCache,RateLimiter, andCircuitBreakerdrop about the same. See Performance. (#780)
0.40.0 - 2026-08-18
Breaking
- 💥 A bad configuration value raises
SettingsValidationError, whichever pattern or component you built. The ten per-module subclasses are removed:CacheSettingsValidationError,CoordinationSettingsValidationError,HealthSettingsValidationError,IdempotencySettingsValidationError,LogSettingsValidationError,MetricsSettingsValidationError,OutboxSettingsValidationError,ResilienceSettingsValidationError,TaskSettingsValidationError, andTraceSettingsValidationError. Catch the base error, which every one of them already subclassed. No caller ever reacted differently by module, and the message names the exact variable. (#750) - 💥
Fallback,Shield, andTTLCacheraiseSettingsValidationErrorfor a bad value instead of lettingpydantic.ValidationErrorthrough. Both areValueError, so anexcept ValueErrorkeeps working. (#750) - 💥 A rejected instance name, table name, or key prefix raises
SettingsValidationErrorinstead of a bareValueError, so oneexceptcovers identity and configuration alike. It still subclassesValueError. (#755) - 💥
cached(),TrustedProxies, andExternalConfigraiseSettingsValidationErrorfor a bad value instead of a bareValueErrororTypeError.cached(ttl=-1)already did, whilelock=,early=, andstale_ttl=did not, so one call had two contracts. (#760) - 💥
Grelmicro(environment=...)refuses a value outside the four tiers instead of storing it. It was accepted in silence, and the backend check then ran as if no tier were declared. The environment variable already warned, so the two doors disagreed. (#760) - 💥
Match.exception()andMatch.exception_cause()raiseValueErrorinstead ofTypeErroron an argument that is not an exception class, and on no argument at all. The same goes for awhen=that is not aMatch, an exception class, a tuple, or a callable. pydantic converts onlyValueError, so the oldTypeErrorescaped every documentedexceptwhen the value came from a variable. Anexcept TypeErroraround a directMatch.exception(...)call stops matching. (#750) - 💥
RedisProviderConfigErrorandPostgresProviderConfigErrorare removed. A provider raisesSettingsValidationError, like every other class. They were the last two per-module subclasses, left behind when the other ten went. (#750) - 💥 An error no longer repeats any part of the value it rejected, including an unknown timezone name, which used to be quoted. The message names a close match instead, as
did you mean 'Europe/Zurich'. (#760)
Fixed
- 🔒
Fallback,Shield, andTTLCacheechoed the rejected value into the error. Pydantic attaches the input to every error it raises, soGREL_FALLBACK_{NAME}_WHENreached the traceback verbatim, andGREL_SHIELD_{NAME}_MAX_RATEfailed with a bareValueErrorfromfloat()carrying the same string. 0.39.0 said every configuration path raisedSettingsValidationError. These three did not. (#750) - 🔒 A validator that names the value it rejected leaked it through the wrapper, which strips only pydantic's own copy of the input. The
when=andtimeout_errorsentries onRetry,Fallback, andShieldno longer report any part of the rejected entry. (#750) - 🐛 A
when=ortimeout_errorsentry that resolved to something other than an exception class raisedTypeError, which escapedexcept SettingsValidationErrorandexcept ValueErroralike, because pydantic converts onlyValueErrorandAssertionErrorinto a validation error.GREL_RETRY_{NAME}_WHEN=os.getcwdhit it. (#750) - 🐛 One bad key in a mounted ConfigMap stopped every other instance from reloading.
Match.exceptionraisedTypeError, which pydantic does not convert, so it escaped the reload loop and aborted the whole resync cycle. An emptyGREL_RETRY_{NAME}_WHENwas enough to trigger it. The loop now skips any value a validator refuses, whatever it raises. (#760) - 🐛 A malformed dict-valued variable such as
GREL_METRICS_HEADERSraisedpydantic_settings.SettingsErrorat app open, notSettingsValidationError. pydantic-settings decodes a complex field before validation runs, so the error never reached the wrapper. Affects theheadersandresource_attributesfields onMetricsandTrace. (#760) - 🔒
SettingsValidationErrornow scrubs the rejected value out of messages built elsewhere. Pydantic writes the input into some of its own text, soGREL_RETRY_{NAME}_BACKOFFechoed an unknown algorithm tag andGREL_CIRCUITBREAKER_{NAME}_IGNORE_EXCEPTIONSechoed the module half of an import path. (#760) - 🔒
RandomBackoffreported both delays it rejected, reachable throughGREL_RETRY_{NAME}_BACKOFF, andUnknownEnvironmentWarningreported theGREL_ENVIRONMENTvalue on both the warning and the logging channel. (#760) - 🐛 An unknown timezone abbreviation was offered a zone on the wrong continent.
PSTandJSTboth suggestedMST,CESTsuggestedEST. A short name that carries no region now gets no suggestion, and a city on its own such asZurichreachesEurope/Zurich, which it could not before. (#750) - 🔒 The scrub covers a custom error code, which is a message someone wrote rather than one pydantic built. It listed pydantic's own types, so a
PydanticCustomErrorraised by a third-party config class was skipped. (#750) - 🐛
PostgresOutboxAdapterraised a bareValueErrorfor a rejected table name while every other adapter raisedSettingsValidationError. (#755) - 🐛
cleanup_intervalraised a bareValueErrorand echoed the value it rejected, in the same constructor whosetable_nameraisedSettingsValidationError. Affects the Postgres and SQLite cache and circuit breaker adapters. (#760) - 🐛 A bad
GREL_SHIELD_MAX_RATEreportedGREL_SHIELD_{NAME}_MAX_RATE, a variable that is not set, sending the operator to the wrong one. The error names the variable it read. (#760) - 🔒 A
reconfigure()that refused a value wrote its message to the log on the reload path, where the message may name the value. Only the error class is logged, which the handler beside it already did. (#760) - 🐛
Trace(instrument=...)validated the directive only when an exporter was active, so a bad one opened cleanly in development and first raised on the deploy that set an endpoint. (#760)
Added
- 👷
just release <version>runs everything ahead of the tag: it cuts the changelog, runs the full preflight, and prints thegh release createcommand rather than running it. Creating the tag stays a conscious act, because it cannot be undone. CONTRIBUTING documents the sequence, which was previously discoverable only by triggering the failures. (#757) - ✅ The public API snapshot records default values, including what a
default_factoryreturns and the return annotation, so a changed default or return type fails CI. (#764) - ✅ Three contract sweeps enforce what the architecture docs publish, discovered by walking the package rather than by a hand-written list.
tests/test_backend_contracts.pycovers thebindcontract over every backend adapter,tests/test_config_contracts.pycovers R3, R4 and the reload rules over everyReconfigurable, andtests/test_construction_contracts.pycovers the declarative door over every class withfrom_config. Each refuses to pass on an empty scan, so an adapter or pattern added later is covered without anyone remembering. (#755) - 📝 An Errors reference page.
SettingsValidationErroris now the one configuration error, and it had no documented home. (#750) - ✅ The public API snapshot covers
grelmicro.outboxandgrelmicro.types. Both were documented in the API reference while their exports went unguarded, so a rename could have shipped unnoticed. A test now reads the reference pages, so documenting a module guards it. (#750)
0.39.0 - 2026-08-16
Breaking
- 💥 Every configuration path that goes through
resolve_configraisesSettingsValidationErrorfor a bad value. The patterns used to let the rawpydantic.ValidationErrorthrough, which carriesinput_valueand so echoed a rejected environment value into the traceback. CatchSettingsValidationError, orValueError, which both it and the pydantic error already are. (Fallback,Shield, andTTLCacheresolve their own configuration and were missed here, fixed in the next release.) - 💥
CircuitBreaker,RateLimiter, and theShieldfactories readGREL_CIRCUITBREAKER_*,GREL_RATELIMITER_*, andGREL_SHIELD_*at construction whenGREL_ENV_LOADis on, like every other pattern. Variables that were silently ignored now apply, and a named instance refuses at startup a variable belonging to a different algorithm than the code runs. Before upgrading a deployment withGREL_ENV_LOADset, runenv | grep -E "GREL_(RATELIMITER|CIRCUITBREAKER|SHIELD)_"in the container and remove or fix what it prints. The environment still never picks the algorithm. - 💥
CircuitBreakerno longer takes a positional config. Pass a pre-built config tofrom_config, which did exactly the same thing.CircuitBreaker("payments")still works, because consecutive-count is a sensible default. - 💥
RateLimiterloses its bare constructor. Build it withtoken_bucket,sliding_window, orfrom_config. There is no default algorithm to fall back on, so the constructor now raisesTypeErrornaming those three. - 💥
Timeout,Retry,Fallback,Bulkhead,Log,Metrics,Trace,Shield, andOutboxno longer takeconfig=. Pass a pre-built config tofrom_config, which did exactly the same thing. One door instead of two. - 💥
grelmicro.clientipmoved togrelmicro.security. ImportTrustedProxies,ClientAddress,ClientAddressReason,ClientAddressMiddleware, andresolve_client_addressfrom there. The logger is renamed togrelmicro.security.clientip, so a filter or a level set on the old name stops matching.
Added
- ✨
grelmicro.securityholds the checks a service runs on an inbound request. Client IP resolution is the first one, and the module states the line grelmicro keeps: it validates what arrives, it never issues credentials. - ✨
micro.install(app)wires a Litestar app. It opens the components on startup, closes them after shutdown, and binds the app around each request so patterns resolve with nobackend=. Call it afterLitestar(...), which builds its middleware stack at construction. - ✨ New
fastapi,starlette, andlitestarextras, sopip install "grelmicro[litestar]"brings the framework along. - 📝 A Frameworks page lists every framework
micro.install(app)supports and what it wires for each. - 📝 Plugins states what grelmicro promises a third-party adapter: how an unsupported algorithm fails, that result tuples grow by name, that integration signatures are frozen, and that
ClockBackendis complete. Adding a member to a protocol breaks every implementer, so these are settled before 1.0. - ✨
Outbox.from_configandTTLCache.from_configcomplete the declarative path. Every primitive that has a config class now accepts one the same way. - ✨
Bulkhead.from_configacceptsuses=to scope providers and components, which the declarative path could not do before. - 📝 Configuration internals states the environment contract as one invariant plus nine rules, with a Settled table recording the assumption behind each closed decision. Written down because this surface changed nineteen times across twelve releases, every time because the contract lived nowhere.
- 📝 Publish/subscribe is a stated non-goal. The outbox consumer page shows the handler publishing through FastStream, and explains why the durable half and the transport half are separate.
Fixed
- 🐛
SettingsValidationErrorraisedIndexErrorinstead of rendering when the failure came from a model-level validator, which has no field location.Lock(retry_interval=0.0001)hit it. - 🐛 The gate-off report told the operator to remove a kind-wide variable that names another algorithm's field. That variable is a legitimate broadcast tuning the instances it fits, so the advice would have deleted working configuration. Only an instance address carries that message now.
- 🐛
MemoryCircuitBreakerAdapter.bindran any algorithm as consecutive-count instead of refusing the ones it does not implement. The three other adapters already refused. - 🐛 The
Shieldfactories ignored the environment while the bare constructor read it, and neither reported a variable set with the gate off.Shield.slow("x")now resolves values from the environment with the preset pinned by code. - 🐛 A
GREL_{KIND}_{FIELD}variable set whileGREL_ENV_LOADwas off went unreported on a named instance. Only the instance address was checked, so the kind-wide variable that would have applied was dropped in silence. Every component was affected. - 🐛
reconfigurerefused the config class its own docs told you to build. An instance constructed through the environment holds a settings subclass, and the runtime-type check rejected the plain config. Every pattern was affected, not just the two gaining the environment path here. - 🐛
docs/config.mdsaidIdempotencysets itsttlin code only. It readsGREL_IDEMPOTENCY_TTLlike every other named pattern. - 🐛 The rate limiter backends rejected an unsupported algorithm kind with
AssertionError, while the circuit breaker backends raisedNotImplementedError. All of them now raiseNotImplementedErrornaming the kind. Every adapter also read the provider client before checking the kind, so an unsupported kind needed a live connection to fail.
0.38.1 - 2026-08-15
Added
- ✨
grelmicro.describeexposes the report modelsGrelmicro.describe()returns, so a caller can annotate a report without reaching into a private module.import grelmicrostill does not pay for them. (#734) - ✨
micro.health,micro.metrics,micro.outbox,micro.ratelimiter, andmicro.circuitbreakerare typed properties. Every first-party kind now keeps its type through lookup instead of resolving asAny. (#734) - ✨ Export the names public signatures already required:
LockConfigandTaskLockConfig(needed byfrom_config),Task(accepted byTasks(tasks=...)andTaskRouter.add_task), and the fourLog*Typeenums (accepted byconfigure). (#734) - ✨
Metrics.provider,Metrics.prometheus_registry, andTrace.providerreturn their OpenTelemetry and Prometheus types instead ofAny. (#734)
Fixed
- 🐛
HealthChecksnever registered for live reload, soExternalConfigcould not retune it from a mounted ConfigMap. It inherited the reconfiguration machinery like every other component but was the only one that did not track itself. (#734) - 🐛
TTLCache.get_or_setandIdempotency.runawaited only coroutines, so a factory returning any other awaitable, such as aFuture, had that awaitable stored as the cached value or the idempotent response. (#734) - 🐛
TrustedProxiesacceptedmax_entries=0, which slices asentries[-0:]and returns the whole list, so the cap silently allowed everything it was meant to bound. Zero and negative values now raise, andmax_hops=0stays valid. (#734) - 🐛 The singleton guard only inspected the incoming component, so a plain component of a singleton kind could register after the singleton. Either side declaring the kind is now enough. (#734)
- 🐛
CallLog.count(name=None)matched calls that never passedname, because an absent key read asNone. (#734) - 🐛
ExternalConfig(reload_interval=0)polled without pause. The interval must now be positive. (#734) - 🐛
@idempotentaccepted a synchronous function that then failed at runtime, and erased the decorated return type. It now requires a coroutine function and preserves the type. (#734) - 🐛 The
CircuitBreaker.consecutive_countdocstring claimed the bare constructor reads environment variables. No circuit breaker path does. (#734)
0.38.0 - 2026-08-15
Breaking
- 💥 The bare
GREL_{PATTERN}_prefix is now the default for every instance of a pattern, not just the default instance.GREL_LOCK_LEASE_DURATION=60used to configureLock("default")alone. It now sets the lease for everyLockthat does not declare its ownGREL_LOCK_{NAME}_LEASE_DURATION. A twelve-lock service tunes one variable instead of twelve. An app that relied on the bare variable reaching only the default instance must move that value toGREL_LOCK_DEFAULT_..., or accept the wider reach. (#733)
Added
- ✨
micro.describe()returns a report of what the app is wired with: every component, its backend, the provider it borrows, and the checks that passed. Credential-like values are masked.python -m grelmicro check app:microrenders it and exits non-zero on a failing check, so CI can gate on the wiring. (#733) - ✨ A provider report names the kinds it declines, not only the ones it serves.
uses=[redis]leaving the outbox unwired was silently swallowed as aNotImplementedError, and is now the first thing the report shows. (#733) - ✨
micro.install(app)resolves the framework through a newgrelmicro.integrationsentry-point group instead of sniffing attributes. A third-party package can now ship an integration, and aFastAPIsubclass declared in your own package resolves correctly because the lookup walks the class's MRO. (#733) - ✨
micro.fake()swaps every backed component onto an in-process store for a block, so a test runs the real code paths against real primitives with no Redis and no Postgres. A fixture can now open the app you actually ship and fake only its backends. (#733) - ✨
micro.get(Cache)keeps the component type. Pass the class to resolve typed, the kind string to resolve a third-party component asAny.micro.get("cache")andmicro.<kind>are unchanged. (#733) - ✨ Every startup diagnostic carries a stable code, such as
backend-scope. The code trails the message, travels as adiagnosticfield on the log record, and links to a section in the new Diagnostics reference. Each warning also gets its own category (BackendScopeWarning,EnvLoadOffWarning, ...) underGrelmicroConfigWarning, so one diagnostic is silenced by category without matching message text and without silencing the rest. (#733)
Fixed
- 🐛 A bare backend matching two protocols resolved by the order of two
ifstatements.runtime_checkablecompares member names only, so everyCircuitBreakerBackendalso matchedRateLimiterBackend, and swapping the checks would have silently registered every breaker as a rate limiter. The most specific protocol now wins, and a backend matching two unrelated protocols raisesAmbiguousBackendError. (#733) - 🐛 A
TypeErrorraised inside a zero-argument__init__was reported as "needs constructor arguments", suggesting a fix that could not work. The arity check now reads the signature before calling, so the real error propagates untouched. (#733)
0.37.4 - 2026-08-14
Fixed
- 🐛 Drop the generic parameter from the
asyncpg.Connectionannotations in the Postgres outbox adapter.asyncpg.Connectionis not generic, sotyping.get_type_hintsonPostgresOutboxAdapterraisedTypeError: type 'Connection' is not subscriptable. Only deferred annotation evaluation kept it from raising at import. (#731)
Docs
- 📝 Let the demo publish a port other than 8000 with
DEMO_PORT.just demo-smokenow picks a free port on its own, andjust demorefuses to start when the port is taken. A local port-forward on 8000 used to answer the probes in the demo's place, because it binds the loopback address while the container runtime binds the wildcard, so both start and neither reports a conflict.
0.37.3 - 2026-08-14
Added
- ✨ Build the cache serializer from the type parameter.
TTLCache[User](ttl=300)serializes withPydanticSerializer(User), so the model is named once instead of twice.TTLCache()andTTLCache[bytes]()still store raw bytes, and so does a type parameter Pydantic cannot adapt. (#684) - ✨ Accept a type wherever a serializer is accepted.
micro.cache.ttl(ttl=300, serializer=User)andIdempotency("http", serializer=Response)build thePydanticSerializer, which is the way in for a factory that has no type parameter to read. (#684)
Changed
- ✨
Idempotency[Response]stores responses withPydanticSerializer(Response)and replays the model itself. It used to store JSON, which cannot even encode a Pydantic model, so the typed form raised on the first response it was given.Idempotency("http")without a type parameter still stores JSON. (#684)
Docs
- 📝 Return a typed value in every example. The README, the guides, the snippets, and the demo app returned
dict, which read as untyped scripting rather than a type-safe library. Each one now returns a Pydantic model, a plainstr, or anint, whichever is shortest for the point it makes. (#679)
0.37.2 - 2026-08-13
Changed
- 📝 Split the six longest guide pages so each one answers a single question. Cache, Idempotency, Coordination, Outbox, Logging, and Providers are now sections with an index page and one page per topic. The top-level URLs are unchanged. (#681)
- 📝 Move Redis connection settings off the cache page and onto Redis and Valkey. A pattern page says which backends work and links out. (#681)
- 📝 Move the logging benchmark table to Benchmarks, next to every other measurement. (#681)
Fixed
- 📝 Fail the docs build on a link whose anchor no longer exists.
mkdocs build --strictcaught a missing file but let a moved heading through. (#681) - 📝 Render the ten snippets that no page included, and delete five that nothing needed. A test now fails on a snippet no page renders. (#681)
0.37.1 - 2026-08-13
Added
- ✨ Add
ValkeyConfig, which accepts Valkey's own URL schemes.ValkeyProviderreadsvalkey://in the constructor and inVALKEY_URL, butfrom_configtook aRedisConfig, which refuses those schemes, so the one path that could not name the server was the config object.from_configstill takes aRedisConfig. (#718)
Fixed
- 🐛 Validate a URL passed to a provider exactly as one read from the environment.
RedisProvider("anything://host")handed the string to redis-py and failed with itsValueError, whileREDIS_URLwas checked against the provider's own URL type and raisedRedisProviderConfigError. Both paths now validate against one type, so they accept the same URLs and fail the same way, andPostgresProviderdoes too. A URL that a client library used to accept and the URL type refuses, such as a host-less authority, now raises at construction. (#718) - 🐛 Carry the Sentinel password through
ValkeyProvider.from_config. It was dropped, so a Valkey Sentinel built from a config connected to the Sentinel servers unauthenticated while the same config onRedisProviderauthenticated. (#718)
0.37.0 - 2026-08-11
Breaking
- 💥 Settle on Component as the one word for app-level wiring.
RateLimiterRegistrybecomesRateLimiterComponentandCircuitBreakerRegistrybecomesCircuitBreakerComponent. Neither ever registered anything: each wraps one backend, so the name borrowed a contract it did not honor. Updateuses=[...]and imports. (#682) - 💥
health_router(registry=...)is nowhealth_router(component=...), matchingmetrics_router(component=...). (#682)
Added
- ✨ Refuse to start when a backend cannot keep the promise its component makes. Declare the tier with
GREL_ENVIRONMENTorGrelmicro(environment=...), and inproductionorstagingaCoordinationorOutboxbound to a memory or SQLite backend raisesBackendScopeErrorbefore the first connection opens. A lock that excludes nothing the moment a second replica starts used to say nothing at all. (#683) - ✨ Add
scopeto every adapter andrequires=to every component that holds a backend. A backend provides a scope (process,hostorcluster), a component requires one, andCoordination(memory, requires="process")declares a single-process deployment instead of muting a check.RateLimiterComponent(redis, requires="cluster")reads the other way and fails the day someone points it at memory. (#683) - ✨ Report the same finding as a warning on two channels when no tier is declared, and stay silent in
developmentandtest. A value naming no tier, such aspreprod, warns and reads as undeclared, so a fleet with its own tier names keeps booting andprodutionis loud instead of silent. (#683) - ✨ Add
micro.check_backends(), which answers for production from a process that declares something else, so a test catches the wiring before a pod does. It raisesBackendScopeErrornaming every binding that does not hold. (#683) - ✨ Set the OpenTelemetry
deployment.environment.nameresource attribute from the declared tier, so one variable gates the check and names the environment in every trace. (#683) - ✨ Read Valkey's own URL schemes wherever a Redis scheme works.
valkey://,valkeys://,valkey+sentinel://andvalkey+cluster://are accepted byValkeyProviderin the constructor and inVALKEY_URLalike, so a deployment can name the server it runs. The URL keeps the scheme you wrote, so logs and errors name that server too.RedisProviderkeeps theredisschemes only. (#716)
Fixed
- 🐛 Register the read-write lock adapters under an entry-point group, like every other component kind. Without one,
readwritelockshort names resolved to nothing and a third-party adapter had nowhere to register. (#714) - 🐛 Add
MemoryProvider.outbox(). The memory outbox adapter ships and the capability matrix lists it, but the provider had no factory for it, soOutbox(MemoryProvider())raised while every other kind resolved. The SQL staging settings (table,auto_migrate,notify) are accepted and ignored, since messages live in a dict. (#714) - 🐛 Wrap a bare read-write lock or schedule backend into its Component.
uses=[RedisReadWriteLockAdapter()]registered no Component at all, so the backend was lifecycled, resolved by nothing, and the pattern failed on first use. Every coordination backend now wraps into the slot it belongs to. (#712) - 🐛 Wire the read-write lock from a bare Provider.
Grelmicro(uses=[redis])registered aCoordinationholding the lock, election and schedule backends and left the read-write lock unset, soReadWriteLockfailed on first use with a message telling you to pass the provider you had already passed. The default Component now wires every coordination backend, and one list drives the wiring, the Provider discovery and the backend scope check, so the next backend added reaches all three. (#710) - 🐛 Adopt a Provider that only a read-write lock backend borrows.
Grelmicrowalks aCoordinationto find the Providers its backends borrow, and the read-write lock was missing from that walk, soCoordination(rwlock=...)with a Provider left out ofuses=started clean and then raisedOutOfContextErroron first use. Pool sharing and open-ordering missed it the same way. (#707) - 🐛 Cover uvicorn's takeover on every logging backend.
stdlib,structlog, andloguruall hand uvicorn's own loggers the matching formatter, so one process emits one format whichever backend runs the app, and each is now tested. (#705) - 🐛 Read a record as a request only when it is one.
UvicornAccessFormattersplit any record carrying five or more positional arguments, so an application record reaching it through a shared handler was rendered as a request line and lost its message. It now splits a record that carries uvicorn's access message, or that comes from uvicorn's access logger, and formats anything else whole. (#705) - 🐛 Keep a log record readable after uvicorn's access formatter has seen it.
UvicornAccessFormattersplit the request fields by rewritingmsgandargson the record itself, so any record carrying five or more positional arguments reached every later reader as"%s %s %s": a second handler on the same logger, a queue listener, or a test readingcaplog. The split now runs on a copy. (#705)
Docs
- 📝 Say that a bare Provider registers every kind it serves except
outbox, which carries handlers and a relay and is built where those are declared. Four docstrings and the wiring guide claimed every kind. (#710) - 📝 Open every feature example on a real backend. First Steps, Cache, Idempotency, and Coordination all started on the memory provider with a note that it runs as-is, so the memory backend read as the normal choice. It is not: a distributed lock on memory gives no mutual exclusion the moment a second replica starts. Each page now starts on Redis and names the extra to install. First Steps adds the one command that runs it. (#680)
- 📝 Keep memory where it belongs and say why it is there. It stays on the testing page, in the backend tabs, in the provider reference, and in the landing example that is about a process-local rate limiter. The cache backend tabs led with Memory and now lead with Redis. (#680)
- 📝 Show the shortest correct wiring.
Grelmicro(uses=[redis])registers a Component for every kind the Provider serves, and a bare backend is wrapped for you, so the landing page, the providers guide, and the resilience snippets no longer name a Component to do what the Provider already does. The Component classes now appear only where they are needed: a second named instance andmicro.override(...). (#682) - 📝 Drop "Registry" from the mental model. First Steps offered "Component or Registry" as one bullet with two names, and the word lingered in the health, rate limiter, and architecture pages. (#682)
- 📝 Fix the
Componentprotocol docstring. It illustrated the protocol with aTasksclass carryingkind = "task", butTasksis a plain async context manager and no such kind exists.Grelmicro.getlisted that kind too. (#682) - 📝 Rewrite the roadmap as direction instead of a feature list. It was a list of named features under Next and Later, which is what the issue tracker already is, so it went stale the day anything shipped: it still offered the FastAPI
Idempotency-Keyintegration, the FastStream integration, and the transactional outbox as future work, all three of which ship. It now describes seven directions, names a few concretes under each as illustration, and points at the tracker once for the queue. Nothing is duplicated, so nothing falls out of sync. (#647) - 📝 Stop framing the roadmap around a 1.0 that is not on the calendar. It opened with "post-1.0 items" while the project runs on
0.x, and split into Next and Later, which read as release buckets without being any. Direction has no buckets, so both are gone. (#647) - 📝 Correct the rate limit header standard. The docs cited RFC 9211, which defines
Cache-Statusand has nothing to do with rate limits. The IETF fields are an Internet-Draft that now definesRateLimitandRateLimit-Policywithq,r, andtparameters, not theRateLimit-Limit/-Remaining/-Resetnames the Rate Limiter table showed. The table matches the draft and shows a real response. (#647) - 📝 Match the README module table to what ships. Resilience listed two patterns out of seven, Cache and Coordination did not name Valkey, Cache named an adapter class instead of its backends, and Client IP was missing. The "why" line now names idempotency and metrics too. (#647)
- 📝 Keep the first README example to one idea. It ended by naming a container and a registry in the section meant to prove how little grelmicro asks for. That sentence is now a link to the example that introduces them. (#647)
- 📝 Stop Shield contradicting the roadmap. Hedged requests were called "not on the roadmap" while the roadmap listed them, fleet-wide retry budgets and deadline propagation read as never rather than planned, and one line said "async-only in 1.0" on a project that ships
0.x. (#647) - 📝 Say once, in the documentation conventions, that the roadmap holds direction and the issue tracker holds the queue. It is not in the per-pull-request checklist, because a page you touch when direction changes is not a page you check on every change. (#647)
- 📝 Make the idempotency quick start actually run. It built a
Grelmicroapp and never installed it, so the handler raisedOutOfContextErroron the first request while the page said it runs as-is. It callsmicro.install(app)now. (#647) - 📝 Stop claiming
RateLimitResultcarries everything an IETF rate limit header needs. It carries the per-request values.RateLimit-Policydescribes the policy, so its window comes from the config you built the limiter with, and the Rate Limiter page now says where to read it. (#647) - 📝 Fix the stale links and lists a reader trips over: the capability matrix pointed at a closed roadmap issue, said Memory adapters take no Provider when
MemoryProviderships, and listed three Providers out of five. The architecture index omitted three of its own pages, the ConfigMap page omitted YAML and TOML, the task page never mentioned cron in its own summary, and the idempotency quick start still opened withCache(MemoryCacheAdapter()). (#647)
0.36.0 - 2026-08-09
Breaking
- 💥 Reject a timezone abbreviation that names no zone.
GREL_LOG_TIMEZONE=CESTused to validate and then fail at startup whenzoneinfocould not load it. Names such asCEST,PST,PDT,EDT,BST, andJSTare DST variants, not zones, and pinning one would freeze the offset year-round. Use the zone:Europe/Zurich, notCEST. Real zone names that look like abbreviations, such asCET,EET,GMT,EST,MST, andHST, keep working. (#645) - 💥 Remove
LogTimeZoneType. UseTimeZoneNamefromgrelmicro.types, which every component that takes a timezone now shares. (#645) - 💥 Starting
Taskstwice raisesTaskStartOperationErrorinstead ofTaskAddOperationError, whose message advised callingadd_taskearlier and did not describe the mistake. (#645) - 💥 A negative
Tasks(shutdown_timeout=...)raisesTaskSettingsValidationErrorinstead of a plainValueError, matching every other component. It still subclassesValueError. (#645)
Added
- ✨ Configure the task timezone once with
Tasks(timezone=...), instead of repeating it on every cron task. ATaskRoutertakes the timezone of theTasksthat includes it, in whatever order the wiring happens, andTaskRouter(timezone=...)gives one group of tasks a different clock. Nearest declaration wins: the task, then its router, then theTasks. A cron task now reports itstimezonefor introspection. (#645) - ✨ Add
GREL_TIMEZONE, one variable saying what wall clock the service runs on.TasksandLogboth read it, and a component variable still wins, soGREL_LOG_TIMEZONE=UTCkeeps logs on UTC under a service that schedules on local time. grelmicro ignores the POSIXTZvariable on purpose, sinceTZfalls back to UTC without complaint on a value it cannot parse. (#645) - ✨ Add
TasksConfigandTasks.from_config(...), so tasks configure like every other component.timezoneandshutdown_timeoutresolve fromGREL_TASK_*.Taskssupports live reconfiguration ofshutdown_timeout.timezoneis startup-only, and an attempt to change it from a mounted ConfigMap is reported rather than applied. (#645) - ✨ Log timestamps carry their UTC offset in the
TEXTandPRETTYformats, rendered asZfor UTC. Without it, a non-UTC log timezone made the repeated hour after a daylight saving transition read as though time ran backwards.JSONandLOGFMTalready carried the offset. (#645) - ✨ Add
ReadWriteLock, a distributed lock that lets every reader in at once and keeps writers alone.lock.readandlock.writeare two views of one lock, each withacquire,acquire_nowait,extend,release, and afrom_threadadapter. It is writer-preferring: a writer that finds readers in the way records an intent, so readers arriving after it wait and writers never starve. Every holder has its own lease, so a reader that died is reaped by the next acquire instead of blocking a writer until a shared expiry fires.ReadGuardandWriteGuardare distinct types, so a function that writes can demand the write guard in its signature, and reading a token from a spent guard raisesLockNotOwnedErrorinstead of handing back a stale one.WriteGuard.poisonedsays the previous writer's lease expired without a release, andawait guard.downgrade()turns a write lease into a read lease with no gap for another writer. Upgrading raisesLockUpgradeErrorrather than shipping a deadlock. Redis, Valkey, PostgreSQL, SQLite, Kubernetes, and Memory all ship an adapter and pass one shared conformance suite. (#686)
Fixed
- 🐛 Import
grelmicro.logon an image with no timezone database.pydantic-extra-typesread the whole timezone database while defining its type, so on a distroless or scratch image the import raisedImportErrorbefore any timezone was configured, and an app that never touched a timezone could not start. grelmicro now validates timezone names itself, and the defaultUTCneeds no timezone database at all. Any other name reports what to install. This also drops thepydantic-extra-typesdependency. (#645) - 🐛 Fire a cron task once when the clocks go back. A wall time the clock passes twice resolved to the second pass, which sits above the durable last-fire state, so a
30 2 * * *task claimed the fire again and ran a second time. An ambiguous time now always resolves to its first occurrence. (#645) - 🐛 Stop a cron task spinning through the repeated hour when the clocks go back. Inside that hour the next matching minute resolves to an instant already past, so the loop woke immediately and read the schedule backend again, for up to an hour on every worker. It now waits for the next minute instead. (#645)
0.35.2 - 2026-08-06
Fixed
- 🐛 Log the ignored-variable report, so it survives a JSON log stream. A
GREL_*variable set withoutGREL_ENV_LOADwas reported throughwarningsonly, which writes plain text to stderr, so in a pod the one line explaining whyGREL_LOG_LEVEL=DEBUGdid nothing was the one line the log collector could not parse. The report now also goes to thegrelmicrologger, which the default backend renders like every other record, with the name in avariablefield an alert can match. A component resolves its config before logging exists, so a report made then waits and goes out as soon as logging is configured.Logrestores the reporting state on exit along with the handlers it replaced, so a second lifecycle formats its own reports. TheGrelmicroConfigWarningchannel is unchanged. (#676)
Docs
- 📝 Add a Deployment guide, which says
GREL_ENV_LOAD=1out loud and puts it in the image rather than the manifest, where one copy always forgets it. Covers the log format and the probe noise, the probe endpoints, the shutdown window againstTasks(shutdown_timeout=...), and a Deployment manifest that applies as it stands. (#676) - 📝 Scope the resolution order to the
GREL_*namespace, in Configuration. Step 2 read as thoughGREL_ENV_LOADgated every environment variable, which no Provider has ever obeyed:RedisProvider()readsREDIS_URLwith no flag, since that name belongs to the deployment rather than to grelmicro, and a missing one fails at construction naming the variable it wanted instead of falling back to a default. Providers says the same where a reader of the env-driven recipe will see it. (#676)
0.35.1 - 2026-08-06
Upgrading
Uvicorn's own log lines change format. configure() now applies your format to uvicorn's loggers, so lines that used to look like this:
INFO: 127.0.0.1:54321 - "POST /orders HTTP/1.1" 200 OK
now match everything else your app emits, with a timestamp, a level field, and structured request fields. That is the point, but a pipeline parsing uvicorn's plain format needs updating, or the old behaviour back:
configure(uvicorn_enabled=False)
A misconfigured GREL_* variable now warns. Setting one without GREL_ENV_LOAD used to pass silently and now raises GrelmicroConfigWarning. A suite running -W error, or pytest with filterwarnings = error, will fail on it.
Fix the configuration, which is the point of the warning:
GREL_ENV_LOAD=1 # read GREL_* variables
or pass the value directly, which never needs the flag:
configure(format="PRETTY")
To keep the warning visible without failing a build, filter the category rather than the message:
filterwarnings = ["error", "ignore::grelmicro.GrelmicroConfigWarning"]
Features
- ✨ Configure the Sentinel password from the environment with
<prefix>SENTINEL_PASSWORD. Sentinel servers commonly run with their ownrequirepass, and the Bitnami Redis chart enables it by default, but nothing read that password so the Sentinel connections went unauthenticated while the data connections worked. OnlyRedisProvider.sentinel(...)carried it, and that factory takes host and port pairs rather than a URL, so an authenticated Sentinel could not be expressed as a URL at all. It applies only when set, never inferred from the data password, becauseAUTHagainst a server withoutrequirepassfails and would break every unauthenticated deployment. Set alongside a non-Sentinel scheme it warns rather than passing silently. Also available assentinel_password=on the constructor and onRedisConfig. (#661) - ✨ Make uvicorn's own logs match the application format, with no log config file. Uvicorn installs its own handlers and turns propagation off, so its lines never reached the handler
configure()sets up and one process emitted two formats, the uvicorn half carrying no timestamp, no level field and no trace context.configure()now reformats them. Its handlers are kept and only the formatter is replaced, so the stderr/stdout split survives and access lines keep their structured fields. Passuvicorn_enabled=False, or setGREL_LOG_UVICORN_ENABLED=false, when uvicorn's logging is configured elsewhere such as with--log-config. (#666) - ✨ Quiet health probe access logs with
ProbeFilter. Attach it to the access logger the same way asDuplicateFilterandRateLimitFilter. Kubernetes polls/livez,/readyzand/healthzevery few seconds forever, and the access log reported each one, so a healthy pod logged almost nothing else. Suppressing them took alogging.Filterthat reflected on the shape of uvicorn's access record. Only responses below400are dropped, so a readiness check that starts refusing traffic still appears. Paths match by suffix, sohealth_router(prefix=...)needs no configuration, andpaths=covers other polled endpoints. (#667)
Fixed
- 🐛 Keep the log line when a field cannot be serialized. An
extra={"url": httpx.URL(...)}raisedTypeErrorout of the formatter, so one unusual value destroyed the record and every field beside it. A value JSON has no representation for is now written as itsrepr, on both the stdlib and orjson paths. Elsewhere serialization still raises, because a cache value that cannot round-trip is a real error. (#666) - 🐛 Set
record.messagewhen formatting, aslogging.Formatterdoes. These formatters build their own mapping instead of calling up, so anything readingrecord.messageafterwards saw an attribute that was never set, including pytest'scaplogand any handler that formats a record twice. (#666) - 🐛 Open a Provider before the Component that borrows it, whatever order they are listed in. A Provider left out of
uses=was already discovered and inserted ahead of its Component, but one listed after it only got a warning and then failed on startup withOutOfContextError, so listing a Provider was worse than omitting it. Both cases are now reordered the same way:uses=says what the app is made of, and grelmicro opens it in dependency order.Grelmicro(strict=True)still raisesLifecycleOrderError, for callers who want the list they wrote to be the list that runs. (#665) - 🐛 Say so when a
GREL_*variable is set but not applied. Environment-driven configuration is opt-in behindGREL_ENV_LOAD, so a documented variable such asGREL_LOG_FORMATwas read by nobody and the default applied with nothing reported. It now raisesGrelmicroConfigWarningonce, naming the variable and the flag. It is its own category so it can be filtered precisely, without silencing everyUserWarningand without matching on message text, the way pytest shipsPytestConfigWarning. Only the exact names a config declares are matched, never the prefix, because Kubernetes injects{SVCNAME}_SERVICE_HOSTfor every Service and a prefix sweep would warn on every pod start. An explicitenv_load=Falseis a decision and stays silent. (#662)
Docs
- 📝 Teach how a value is resolved, in Configuration. Keyword arguments, environment behind
GREL_ENV_LOAD, and a file throughExternalConfig, with a local development recipe that does not need exported variables. Says plainly thatExternalConfigreconfigures live components and notLog, so log format in local development comes fromconfigure(...)or a loaded.env. (#662) - 📝 Say why orjson is not selected just because it is installed. The two serializers disagree on some payloads:
NaNandInfinitybecomenull, and a non-string dict key raises instead of being coerced. Auto-selecting on importability would let an unrelated dependency change what logs say, or turn a working log call into an exception. The choice stays explicit, and the reasoning is now written down. (#667) - 📝 Put the opt-in warning above every environment variable table, written once and included, so a reader who lands on a module page from a search engine sees it without following a link. The logging page also no longer claims every knob is an environment variable without saying when they are read. (#662)
0.35.0 - 2026-08-05
Upgrading
/healthz stopped sending null fields. A check that passed no longer carries error, and a check with no details no longer carries details.
-{"status": "ok", "critical": true, "error": null}
+{"status": "ok", "critical": true}
A consumer that reads error unconditionally needs to treat it as absent:
error = check.get("error") # None when the check passed
if error is not None:
alert(name, error)
A failing check still carries error, and status and critical are still on every entry, so a dashboard reading those needs no change.
A HealthChecks no longer removes your backends. Listing any Component used to switch provider auto-registration off entirely, so an app that added health checks lost its cache and locks with no warning. If you listed components explicitly only to work around that, the short form works now:
-micro = Grelmicro(uses=[
- Coordination(redis), Cache(redis), RateLimiterRegistry(redis),
- CircuitBreakerRegistry(redis), tasks, health,
-])
+micro = Grelmicro(uses=[redis, tasks, health])
Explicit components still win for their own kind, so mixed wiring keeps working untouched. Two or more providers still fill no defaults, so HealthChecks(auto_health=True) across several providers is unchanged.
Redis credentials split across two variables now work. REDIS_PASSWORD next to a REDIS_URL was read and then dropped, so the client connected unauthenticated. If you worked around that by building the provider from explicit settings, the plain form is enough again:
-provider = RedisProvider.sentinel(
- sentinels=[("host", 26379)], service_name="mymaster", password=settings.password
-)
+provider = RedisProvider()
REDIS_URL=redis+sentinel://a:26379,b:26379/mymaster/0
REDIS_PASSWORD=...
That URL is the second half: redis+sentinel:// and redis+cluster:// are now accepted from the environment and from RedisConfig, including the multi-host form, so the topology no longer has to be hard-coded to be expressible.
One combination newly raises instead of passing silently: a URL that already carries credentials and a separate REDIS_PASSWORD. Keep the password in one place. The same applies to VALKEY_*.
Breaking
- 💥 Back off per kind instead of disabling every provider default. Listing any Component used to turn provider auto-registration off entirely, so adding a
HealthCheckssilently removed the cache and the locks and the firstLock("cart")raised, naming the lock rather than the health registry that caused it. A Component now claims its own kind and the Provider fills the rest, which reads as one sentence: explicit wins, the Provider fills the rest. A Component of a kind no Provider serves (HealthChecks,Log,Trace) claims nothing and suppresses nothing. Two or more Providers still fill no defaults, since neither can be the default for a kind they both serve, soHealthChecks(auto_health=True)across several Providers is unchanged. (#655) - 💥 Leave
erroranddetailsout of a/healthzcheck that has neither. A passing check sent"error": nullon every poll, and with details enabled it sent"details": nulltoo, so the highest-frequency response in the service spent bytes reporting that nothing happened. A passing check is now{"status": "ok", "critical": true}, and a failing one still carries itserror. The OpenAPI schema types both fields as a plain string and object instead of promising a nullable value that never arrives. Read an absenterroras a pass. A consumer that required the key needs updating. (#649)
Features
- ✨ Skip a
Noneentry inGrelmicro(uses=[...])andBulkhead(uses=[...]). A component registered only for one backend now stays a plain expression,uses=[Log(), redis if backend == "redis" else None], instead of a star-unpacked conditional or a helper function.micro.use(None)still raises, because a single call can be guarded withif. (#646) - ✨ Export
Usable, the type of everythinguses=accepts. Building the list in a variable did not type-check, becauseComponentwas the only exported name and aProvideris not aComponent. Annotate itlist[Usable]and append either. (#646)
Fixed
- 🔒 Apply
REDIS_PASSWORDto aREDIS_URLthat carries no credentials. The password was read, validated and then dropped, so the client connected unauthenticated and the first command failed withNOAUTH, pointing at Redis rather than at the configuration. Host in a ConfigMap and password in a Secret is the shape the config docs recommend, and it was the one shape that did not work. A URL that already carries credentials plus a separate password now raises instead of silently preferring one, since the two can disagree. (#653) - 🐛 Accept
redis+sentinel://andredis+cluster://fromREDIS_URL,RedisConfig, andVALKEY_URL. The environment andfrom_configpaths validated against a stricter type than the constructor, so the topology that most needs environment configuration was the one that could not be expressed there. Multi-host authorities such asredis+sentinel://a:26379,b:26379/mymaster/0validate too. All three paths now share one URL type, so they cannot drift apart again. (#654) - 🐛 Restore the component registry when an
override(...)component fails to open. The registry was mutated one component at a time before the restore was armed, so a mock that raised on__aenter__stayed installed for the rest of theasync with micro:block. The next lookup resolved the broken mock instead of the real component, with nothing reporting it, which in a session-scoped fixture leaked into every later test. (#651) - 🐛 Instantiate a bare class passed to
Bulkhead(uses=[...]). The parameter documented the same shape asGrelmicro(uses=[...]), which accepts a class with no parens, but the bulkhead entered the class object itself and failed on startup. (#646) - 🐛 Reject
micro.use(None)with a message naming the fix. It appendedNoneto the item list and failed later inside the app lifecycle, pointing at nothing. (#646)
Docs
- 📝 Show how to register a component conditionally, in Wiring an App. Covers the inline
Noneentry, theUsable-annotated list, and how a Provider registers differently throughusethan insideuses=. (#646) - 📝 Add a Providers recipe for taking the managed connection and nothing else. Application state that is not a cache, a lock, or a rate limiter is still yours to read and write through
provider.client, with the lifecycle already handled. (#646) - 📝 Document the
/healthzreport body, with a passing and a failing check side by side. (#649) - 📝 Keep adapter classes out of the examples a first-time reader meets. The guide opened with
MemoryLeaderElectionAdapter()andCache(MemoryCacheAdapter())before the reader had any reason to know what an adapter is. Every quick start now names a provider once,uses=[MemoryProvider()]oruses=[redis], and the pattern follows with no wiring in sight. Adapter references in the snippets went from 56 to 8, and the 8 that remain are Kubernetes and the outbox memory backend, where choosing a backend is the subject. Nothing about the API changed, so no code needs updating. (#644)
0.34.3 - 2026-08-05
Security
- 🔒 Point client address identity checks at
forwardedinstead ofdegraded.degradedis False forUNTRUSTED_PEER, so one mistyped CIDR inTrustedProxiesleft every request carrying the proxy's own address, and the guard the docs recommended admitted it. The private network gate in the health docs then showed details to everyone, which is the bypassdegradedwas added to close.forwardedis True forRESOLVEDalone, so it refuses that request. The docstrings and the reason table now say which outcomes mean the peer is the caller and which mean the address is one of your own proxies. If nothing fronts your app,forwardedis never True and there is nothing to gate on, so read which check to use before copying the new guard into a direct deployment. (#636)
Features
- ✨ Log an untrusted peer that sends a non-empty
X-Forwarded-ForwhileTrustedProxiesis not empty. That combination is either a caller sending the header directly or a proxy of yours missing from the trusted set, and the misconfiguration had no other symptom. Thegrelmicro.clientiplogger gets one line per peer, for at most eight peers, so a busy proxy cannot flood it and a caller probing the header cannot take the line your own proxy needs. (#636) - ✨ Cache an async generator with
@cached. Iterating the decorated producer streams its items and stores the assembled list once it finishes, andcollect()reads that same entry whole, so a streaming endpoint and a buffered one share one producer, one key and one execution. Only a completed sequence is stored, so a reader that stops early and a producer that raises part way both leave the key untouched rather than publishing a truncated result. (#501)
Fixed
- 🐛 Report a truncated forwarded chain as
TOO_MANY_ENTRIES. The reason existed but was never returned. A header longer thanmax_entrieswhose read window held only trusted proxies came back asCHAIN_EXHAUSTED, which claims every entry was seen. (#636) - 🐛 Stop
@cachedhanging on a generator function. An async generator is not a coroutine function, so it took the sync wrapper, which blocks its own thread waiting on the cache loop. Decorating one wedged the event loop on the first call, with no error. Async generators are now supported, and a sync generator raises at decoration time, since it yields its items once and a cached one would replay as empty. (#501)
0.34.2 - 2026-08-02
Security
- 🔒 Refuse an idempotency key that cannot separate one caller from another. A
key_makerreading a value that was not set yet foldedNoneinto the key, so every caller shared one entry and could replay each other's stored response, while the request still answered200. A key that is partly missing does not fail, it merges, and the widening was invisible.IdempotencyMiddlewarenow raisesIdempotencyKeyMakerErrorwhen the key is empty, drops the client's key, or carries an unresolvedNone. - 🔒 Stop the multi-tenant
key_makerexample reading an unauthenticated header. It took the tenant fromX-Tenant, which the client sets, so a caller could name the tenant whose entry they read. It now folds in an authenticated identity, uses a separator an identity cannot contain, and raises rather than building a partial key. The docs also say plainly that a client address is not a tenant identity, because carrier-grade NAT puts many subscribers behind one.
Docs
- 📝 Say that a
key_makerreading the scope needs its source middleware outsideIdempotencyMiddleware.ClientAddressMiddlewareadded the wrong way round leavesclient_addressunset when the key is built, so the key folds inNone, every caller shares one entry, and the request still answers200. A key that looks like it separates callers and does not is worse than nokey_makerat all. - 📝 Warn that a mounted sub-application does not fail loudly. A mount is an ordinary call in the same task, so the host's request scope is still bound inside it. A sub-application that forgot
installtherefore resolves against the host's components rather than raising, and two applications that look isolated share one store with nothing reporting it.check_ambient_bindingcatches it, per app.
Internal
- 👷 Run the Python matrix in the release preflight.
just release-checktested only the primary Python, so 0.34.0 passed every check it ran and still failed its release on 3.14, which burned the version. The newjust test-matrixruns the unit and integration tiers on every other Python in the matrix, and reads that list out of the workflow so the preflight cannot drift away from what CI runs.
0.34.1 - 2026-08-02
Internal
- ✅ Stop the release matrix failing on tests that were racing their own lease. Two runs of the 0.34.0 release failed on Python 3.14, each on a different test. A leader election test tripped a 5s per-test timeout, and a lock test asserting
extend()keeps its fencing token got a fresh one instead, because the 10 ms lease had already lapsed andextendre-acquired. Neither was a product bug: 3.14 runs the modules about twice as slow as 3.12, on a runner already oversubscribed by-n auto. The affected tests now use the generous-lease fixture the file already had for this, and the timeout that only guards against hangs is generous enough to survive the matrix.
0.34.0 - 2026-08-02
Tagged but never published. The release run failed on the Python 3.14 matrix before the publish step, so this version does not exist on PyPI. Everything below ships in 0.34.1.
Breaking
- 💥 Refuse
@cachedon a method unless it names its key. The default key is therepr()of every argument, and on a method that includesself, which was wrong in both directions: two instances whoserepr()matched shared one entry, so a call on one returned the other's value, and an instance using the defaultrepr()carried a memory address, so its key changed on every restart. Neither said anything. Decorating a method withoutkey=orkey_maker=now raisesTypeErrorat decoration time. Pass a key naming what identifies the entry, such askey="repo:{user_id}". (#600)
Fixed
- 🐛 Keep the grelmicro request scope outside every other middleware.
IdempotencyMiddlewarehad to be added beforemicro.install(app), and the wrong order raised nothing at setup, so the first failure arrived in production from a client that actually sentIdempotency-Key.installnow places the binding middleware outermost when the stack is built, so either order works, and reads the placement back on startup so a stack that still ends up wrong raisesAmbientBindingErrorat boot. (#599) - 🐛 Start more than one worker against a fresh Postgres.
CREATE TABLE IF NOT EXISTSchecks and creates in two steps, so two workers starting together both passed the check and one crashed on the row type the table creates. Every Postgres adapter now installs its schema under an advisory lock, as the outbox already did. (#595) - 🐛 Give each worker of a pre-fork server its own coordination identity.
gunicorn --preloadbuilds the application once and forks, so every child inherited the identity generated in the parent. Two workers presented the same lock token, and every child read the leader record holder as itself, so all of them led at once. A child now appends its own random suffix.uvicorn --workers Nspawns instead of forking and is unaffected. (#595) - 🐛 Report a task fire that never ran.
grelmicro.task.runsonly counted fires that reached the body, so a schedule backend or a lock that stopped answering left no metric at all and looked exactly like a task with nothing due. A fire now always lands on the counter:coordination_errorwhen coordination failed,missedwhen no worker ran it, andskippedwhen a peer handled it. The bare total counts more than it did, so read the migration note if a chart treats it as the run rate. (#605) - 🐛 Warn when a cron fire is dropped for coming back too late. Past
misfire_grace_secondsthe fire was skipped with no log and no metric, so a task that never replayed said nothing at all. (#605) - 🐛 Record a fire that never reached the body on
last_fire. An introspection endpoint reading it during a coordination outage reported the previous successful fire and looked healthy. (#605)
Internal
- 👷 Add
justrecipes for the release path.just release-check <version>runs what the Release workflow runs, before the tag exists, so a failure costs nothing rather than burning an immutable tag.just verify-release <version>downloads a published wheel and sdist and verifies build provenance for each, which turns the SLSA Build Level 2 badge into something anyone can check.just release-notes <version>prints the changelog section the GitHub Release body should hold. (#584) - ✅ Pin both sides of the span exception check, so the coverage gate stops failing at random. Only one side had a test of its own. The other was covered whenever some unrelated test happened to raise inside a non-recording span, which depends on the order
pytest-randomlypicks, so a run could report_span.pyat 96% and fail--fail-under=100on a pull request that changed nothing near it. - 🧪 Verify the Patterns across real process boundaries. A new multiprocess tier races worker processes against Redis, so cross-process exclusion, single leadership, and the per-worker memory adapters are asserted rather than read off the code. The demo smoke stack now runs two uvicorn workers and checks the rate limit holds across both. (#595)
Docs
- 📝 Name the hazard
env_load=Falseguards against. Env reads fill every field the caller did not pass, so a config half taken from aSettingsobject silently gets the rest from the environment. ASettingsdefault that differs from the environment is dropped without a word. (#606) - 📝 Treat outbox retention as a decision the caller makes. A payload sits in the database until its row is deleted, so a single-use secret is at rest for as long as the row lives. The default deletes a delivered row, which is the safe end, but a default is not a guarantee. Pin
keep_deliveredrather than inherit it, and note that dead rows are never purged automatically. (#607) - 📝 Say that a stored idempotent response sits at rest for
ttl. The same audit: the middleware stores the whole response, sottlis a retention window and not only a replay window. (#607) - 📝 Correct the pre-fork guidance for coordination. It told the reader to pass an explicit
workeridentity, which every child inherits just the same, so the advice turned a likely collision into a certain one. (#595) - 📝 Say that
Bulkhead.max_concurrentandShield.max_rateare per worker process. Both read as a deployment-wide ceiling, so four workers quietly gave the dependency four times the configured number. (#595)
0.33.0 - 2026-07-31
Features
- ✨ Add
ClientAddress.degraded, which marks a result whose address is the connecting peer rather than the caller. Anything treating the address as an identity must refuse when it is set. (#609) - ✨ Add
grelmicro.clientip, which resolves the real client address behind a reverse proxy.X-Forwarded-Foris append-only, so its leftmost entry is attacker-controlled. The resolver reads the header only when the connecting peer is a trusted proxy, walks right to left, and returns the first entry no trusted proxy wrote. The trusted set is required and there is no wildcard. (#609)
Security
- 🔒 Refuse to show health details when the client address is a fallback. Resolving alone was not enough: a forged chain that could not be believed still returned the proxy's own private address, so the
is_privatecheck admitted everyone again.ClientAddress.degradedmarks every such case. (#609) - 🔒 Stop the health-detail example from showing details to everyone behind a proxy. It gated on
request.client.hostbeing private, which is the proxy's own address for every external caller, sois_privatewas true for all of them. It now resolves the client. The rate limiter example keyed on the same value, giving every caller one shared bucket. (#609)
Internal
- 👷 Enforce the coverage total on the pull request that changes code, not in the next nightly. The slow and integration tiers now run on a code-touching pull request or push, on the primary Python only, so the combined 100% total is measurable there. The Python matrix and the demo tier stay on the nightly, dispatch, and release paths. About two minutes per pull request. (#602)
- 👷 Stop a Codecov upload from failing CI. The test-results action crashed on a transient network error, which fails the step whatever
fail_ci_if_errorsays, so a green test run with a passing coverage gate was reported as a failure. Uploads are telemetry and now cannot gate a build. (#602)
Docs
- 📝 Write down the pre-1.0 deprecation policy. A rename before 1.0 is a clean cut, with the reasoning recorded so the question does not get re-litigated per rename. (#613)
- 📝 Add a migration page, one note per minor from 0.30 onward, listing only what an upgrade requires. It leads with a symptom table, so an adopter several versions behind can match the error they see instead of reconstructing the path from every release's notes. (#608)
0.32.9 - 2026-07-30
Fixed
- 🐛 Report a failed background cache refresh. The task's exception was discarded, so a permanently failing recompute silently degraded every hot key back to a cold miss. It now logs a warning naming the key and records
grelmicro.cache.early_refresheswithoutcomeanderror.type. (#605) - 🐛 Hold a strong reference to a background cache refresh task. The event loop keeps only weak references, so the task could be collected before finishing and would then report nothing at all. (#605)
- 🐛 Report a failed
Shieldcache write. It was logged at debug with no counter, so the copy the shield serves when the primary fails could stop being written and nothing said so until the incident it exists for. (#605) - 🐛 Rate-limit the
Shieldcache warning to once a minute per shield, and report a failing cache read as well as a write. A cache write rides along with every successful call, so an unreachable store would otherwise log once per request. The counter still records every failure. (#605) - 🐛 Remove the Postgres outbox termination listener before releasing the connection. asyncpg calls it on any close, so a clean shutdown warned about a lost listener, and a shared pool could warn again when it later recycled that connection. (#605)
- 🐛 Back off when an outbox backend's
wait_notifyfails instantly. The relay returned with no delay and spun its claim query, one warning and one query per iteration. (#605) - 🐛 Report a lost Postgres outbox listener connection. Delivery silently fell back to polling, bounded by
poll_interval, until the process restarted. (#605) - 🐛 Report a crashed outbox relay or purge loop when it crashes.
asyncio.waitnever re-raises, so a crash stopped delivery for the life of the process, and reporting it only at shutdown would have arrived long after it mattered. Relay wait errors moved from debug to warning. (#605)
Docs
- 📝 State the background-failure contract: work with no caller to raise into always becomes observable through a counter, a warning, or a health degradation, never a silent suppression. (#605)
0.32.8 - 2026-07-30
Internal
- 👷 Enforce the 100% coverage claim in CI. Every tier reset coverage instead of accumulating, so CI only ever measured the unit tier and no
fail-undergate ran there at all. The claim was checked only by a local hook that pre-commit.ci skips. The slow and integration tiers now append, and the full path fails under 100%. (#602) - 👷 Make Codecov's project status advisory. It compared a pull request's unit-only coverage against a base built from the full tier, so it reported a regression that was a tier mismatch rather than a real one. (#602)
0.32.7 - 2026-07-30
Features
- ✨ Add
refresh()to a@cachedfunction. It recomputes for the given arguments, overwrites the stored entry, and returns the new value, so an endpoint honouringCache-Control: no-cachekeeps the decorator's key handling and tag invalidation. Concurrent refreshes each recompute rather than folding, and an error propagates instead of serving stale. (#500) - ✨ Add
IdempotencyMiddleware. A request carrying anIdempotency-Keyheader runs once, and a retry replays the stored response without reaching the handler. Pure ASGI, so it works on FastAPI, Starlette, and Litestar alike. (#503) - ✨ Bound the single-flight wait with
wait_timeout=on anIdempotencyblock and onrun(). A duplicate that waits longer than that raisesIdempotencyWaitTimeoutError, which subclassesTimeoutError, instead of holding the caller indefinitely. (#503) - ✨ Add
document_idempotency(app), which describes the installedIdempotencyMiddlewarein the OpenAPI schema. A middleware is invisible to the generated schema, so a client built from it never learns the header exists. (#503)
Fixed
- 🐛 Accept a SQLAlchemy-style Postgres URL.
PostgresProvidertookpostgresql+asyncpg://without complaint and then failed at connect time, so every adopter stripped the driver suffix by hand. The suffix is now dropped when the URL is resolved, from a keyword, the environment, or a config, whatever the scheme's case. (#596) - 🐛 Type the helpers a
@cachedfunction exposes.cached()returned a plainCallable, socache_info()andcache_clear()were documented but invisible to type checkers, and calling them failed a downstreamtyormypyrun. It now returnsCachedFunction. (#500) - 🐛 Release the single-flight lock correctly when an
Idempotencyblock is cancelled while acquiring it. The lock was bound to the block before it was held, so the cleanup path tried to release a lock the block did not own and raisedLockNotOwnedErrorover the original error. (#503) - 🐛 Release the in-process idempotency lock even when the distributed release is cancelled mid-flight. A cancellation there left the key locked for the life of the process. (#503)
Internal
- 🚨 Satisfy the stricter type narrowing in
ty0.0.62. Two existing call sites lost a type parameter throughisinstance, which failed the lint gate on the dependency bump rather than in any user-visible way. (#598)
Docs
- 📝 Document how to report a bug: where to file, what a report needs, and what happens after you file it. (#592)
- 📝 Add GitHub issue forms for bug reports and feature requests, so a report arrives with the version, the backend, and a runnable reproduction. (#592)
- 📝 Add the OpenSSF Best Practices badge, now passing at 100%. (#592)
0.32.6 - 2026-07-29
Fixed
- 🐛 Take the state lifetime off the in-memory circuit breaker's admission path. Every call recomputed the lifetime and read the clock, which made
try_acquire2.4x slower from 0.32.2 on. Entries now carry an absolute deadline, and a closed circuit admits from one dictionary lookup. (#582)
Docs
- 📝 Lead the README with one sentence that says what grelmicro is, and add the SLSA Build Level 2 badge. (#581)
0.32.5 - 2026-07-28
Security
- 🔒 Attest build provenance for every release. Each published artifact now carries a provenance attestation signed on GitHub infrastructure separate from the build job, and the release verifies it before publishing, so a missing or invalid attestation fails the release. (#575)
Docs
- 📝 Move the documentation site to grelmicro.grel.info. The old GitHub Pages address redirects, so existing links keep working. (#577)
- 📝 Add a root
CHANGELOG.mdpointing to the published changelog, so tools that look for one at the repository root find it. (#576)
0.32.4 - 2026-07-28
Docs
- 📝 Document the recommended
CircuitBreakerlifecycle. Build one per name at module level. The circuit lives in the backend keyed by name, butlast_error, the call totals, and the cached state are per instance, so a per-request breaker reports empty metrics and logs a transition that never happened. (#497) - 📝 Correct the cross-replica story for cron tasks. The task page said at-most-once always needs a
TaskLockorLeaderElection, which is true foreveryand false forcron. Cron claims each fire against the schedule backend, so a wiredCoordinationcomponent is all it needs. (#502) - 📝 Warn against gating a cron body on leadership. Winning the claim advances the durable state before the body runs, so an early return consumes the fire without doing the work. (#502)
- 📝 Document what the
autotrace exporter selects. It resolves to OTLP HTTP or to the no-op and never to gRPC, whatever the endpoint URL or the installed exporter packages. (#498)
0.32.3 - 2026-07-28
Fixed
- 🐛 Bound the cache cleanup sweep. It deleted every expired row in one statement, so a large backlog held one long delete against the table. Each pass now takes at most 1000 rows and the interval is jittered, so replicas do not sweep in lockstep. (#496)
- 🐛 Log cache cleanup failures instead of swallowing them. A failing sweep was silently suppressed, so a cache that stopped reclaiming disk gave no signal. (#496)
0.32.2 - 2026-07-28
Features
- ✨ Add
CircuitBreaker.keyed(key)to give each tenant, endpoint, or model its own circuit, with independent counters, state, and cool-down. (#496) - ✨ Circuit breakers now reclaim their stored state instead of keeping it forever, so a dynamic key set no longer grows the backend without bound. Redis expires the key, Postgres and SQLite sweep hourly via
cleanup_interval=. (#496)
Upgrading
Circuit-breaker rows written before this release carry no activity timestamp, so an already-open circuit on Postgres or SQLite reads as expired the first time the new code touches it and starts again from CLOSED. This happens once, on the first call after the upgrade. Circuits held open by isolate() are unaffected.
0.32.1 - 2026-07-28
Re-cut of 0.32.0, which never reached PyPI. A flaky test failed the release run, so publishing was skipped. The 0.32.0 tag is immutable, so the same contents ship here. See 0.32.0 for the changes.
Internal
- ✅ Stop
test_lock_acquire_nowait_would_blockracing its own lease. The test asserts that a second worker is blocked, but held the lock on a 10 ms lease, so a loaded runner could let the lease lapse before the second worker tried. It then acquired cleanly andWouldBlocknever fired, which failed the 0.32.0 release on Python 3.14. Contention tests now use a lease that outlives scheduling jitter, matching the from-thread twin.
0.32.0 - 2026-07-28
Breaking
- 💥 Credential-carrying URL fields are now
SecretUrl:urlonPostgresConfigandRedisConfig, andendpointonTraceConfigandMetricsConfig. Eachheadersvalue onTraceConfigandMetricsConfigis now aSecretStr. Passing a plain string still works, but reading the value back needs.get_secret_value(). (#550) - 💥
SQLiteLockAdapterandSQLiteScheduleAdapternow takeprovider=instead ofpath=, like every other SQLite adapter. ReplaceSQLiteLockAdapter("app.db")withSQLiteLockAdapter(provider=SQLiteProvider("app.db")), or pass the provider toCoordination(sqlite)and let it build both. A missing path now raisesSettingsValidationErrorinstead ofCoordinationSettingsValidationError. (#546)
Features
- ✨ Add
grelmicro.types.SecretUrl, a URL that never shows its credentials. It displays the URL with the userinfo password and credential-like query values replaced by***, so the scheme, host, and path stay readable in logs. Parametrize it with any pydantic URL type to keep that type's validation:SecretUrl[RedisDsn],SecretUrl[PostgresDsn], or a bareSecretUrlfor any URL. (#550)
Fixed
- 🐛 Capture the running event loop in
PostgresLockAdapterandPostgresScheduleAdapter. Both protocols require a_loopattribute, but neither adapter set it, soLock.from_threadandTaskLock.from_threadraisedAttributeErroragainst a Postgres backend instead of working. Every other backend already captured it. (#541) - 🐛 Share one SQLite connection across every component on the same file.
SQLiteLockAdapterandSQLiteScheduleAdapteropened their own connection, so an app pairing a lock with a cache held two. They now borrow the provider's connection and shared lock, soGrelmicrocan dedupe them onto one provider like every other adapter. (#546) - 🐛 Adopt the provider behind a
Coordinationschedule backend. Provider discovery walked the lock and election backends only, so a schedule-onlyCoordinationleft its provider unopened and duplicate providers undeduped. (#546)
Security
- 🔒 Mask credentials embedded in connection URLs.
urlonPostgresConfigandRedisConfig, andendpointandheadersonTraceConfigandMetricsConfig, appeared in full inrepr(),model_dump(), andmodel_dump_json(). Nothing changes on the wire. (#550) - 🔒 Stop echoing rejected values from
PostgresConfig,RedisConfig,TraceConfig, andMetricsConfig. A mistyped URL carried its password into theValidationErrortext. (#550)
Internal
- ✅ Add
tests/test_adapter_contracts.py, which asserts every first-party adapter initializes_loopand captures the running loop on__aenter__. AProtocolattribute annotation declares the requirement but never creates it, so both type checkers pass an adapter that omits it. (#541) - 👷 Clear 16 modules from the mypy override ladder, leaving 3. The
uses=resolver, the optional-import rebinding, and thefunctools.wrapsreturns now type-check under both checkers. (#541) - 🔥 Drop a stale
# type: ignoreingrelmicro/metrics/_component.py. grelmicro suppresses with# ty: ignoreonly. (#541) - ⬆️ Bump
ruffto 0.16. Markdown formatting is now stable, so the formatter skips*.mdand leaves the README and docs examples written as they read best. The newCPY001rule stays off: the MIT licence lives inLICENSE, not in a per-file header. (#557) - 👷 Pin the
ty-checkpre-commit hook toTY_MAX_PARALLELISM=1, matching CI. ty resolves inference cycles in whichever order threads reach them, so a local run could flag a diagnostic that CI did not. (#551) - 📝 List each Provider once in the SQLite and bulkhead examples. A Component that borrows a Provider already adopts its lifecycle, so the bare Provider beside it was redundant. The Redis and Postgres examples were already written this way. (#559)
0.31.0 - 2026-07-27
Breaking
- 💥 Default
Metrics()to theautoexporter. It exports over OTLP HTTP when an endpoint is configured and otherwise auto-disables into a true no-op, so an unconfiguredMetrics()no longer falls back tolocalhost:4318. Register it unconditionally: an auto-disabledMetricsinstalls no provider and never conflicts with a second app. (#508) - 💥 Credential fields are now
SecretStr:basic_auth_passwordonTraceConfigandMetricsConfig, andpasswordonPostgresConfigandRedisConfig. Passing a plain string still works, but reading the value back needs.get_secret_value(). (#549)
Features
- ✨ Add
basic_auth=(username, password)toMetrics, matchingTrace. grelmicro builds theAuthorization: Basicheader and attaches it to the OTLP exporter directly, bypassing the fragileOTEL_EXPORTER_OTLP_HEADERSencoding. From the environment, setGREL_METRICS_BASIC_AUTH_USERNAMEandGREL_METRICS_BASIC_AUTH_PASSWORD. (#507)
Fixed
- 🏷️ Preserve the wrapped function's signature through the resilience decorators. Applying
Retry,Shield,Bulkhead,Timeout,CircuitBreaker, orFallbackno longer erases the parameter and return types, so calls to a decorated function stay type-checked. (#545) - 🐛 Declare
_looponLockBackend,ScheduleBackend,CacheBackend, andCircuitBreakerBackend. The attribute was already required in prose, so a third-party adapter that omitted it type-checked and then raisedAttributeErroron the firstfrom_threadcall. (#540) - 🐛 Raise a clear error when
Lock.from_threadorTaskLock.from_threadruns before the backend is opened, matching the cache and circuit-breaker behavior. (#540) - 🐛 Point the circuit-breaker worker-thread error at
async with micro:instead ofgrelmicro.lifespan(), which is not a public API. (#540) - 🐛 Raise a clear error when installing the FastStream ambient middleware on an app with no broker, instead of an
AttributeError. (#540)
Security
- 🔒 Mask credentials in config objects.
basic_auth_passwordonTraceConfigandMetricsConfig, andpasswordonPostgresConfigandRedisConfig, were plainstrand so appeared inrepr(),model_dump(), andmodel_dump_json(). All four are nowSecretStr. Nothing changes on the wire. (#549)
Internal
- 🐛 Set
strictdirectly onLogTimeZoneTypeinstead of using@timezone_name_settings. The decorator's return type references the class it decorates, sotyresolved the subclass differently between parallel runs and failed about 9 runs in 10. Runtime behavior is unchanged. (#549) - 📝 Document the
_loopcapture contract in the third-party adapter guide. The protocols require it, but the guide did not mention it, so a new adapter could follow the docs and still fail on the firstfrom_threadcall. (#549) - ✅ Widen the circuit-breaker cool-down margins in the SQLite, Postgres, and Redis backend tests. The rejection assert now uses a 60s cool-down so a stalled runner cannot let the window elapse between two adjacent calls, and the elapse asserts wait 5x the cool-down instead of racing a 0.05s gap. (#548)
- 👷 Enable the Pydantic mypy plugin and shrink the mypy override ladder from 29 modules to 19. Ten modules now type-check under both checkers. (#547)
- ♻️ Declare
arbitrary_types_allowedin the_BaseShieldConfigclass kwargs instead of a secondmodel_configassignment. Pydantic merged both, so the settings are unchanged. (#547) - 🔥 Drop the inert mypy
# type: ignorecomments. grelmicro type-checks with ty, which never read them, andty checkstays clean without them. (#539) - ✅ Add
tests/typechecking/, a suite ofassert_typeclaims on the public API, checked by both ty and mypy. grelmicro shipspy.typed, so these annotations are part of the contract. (#544) - 👷 Run mypy in CI alongside ty. The 30 modules that do not pass yet are listed in
[[tool.mypy.overrides]]and tracked in #541. (#544) - 🔥 Delete
grelmicro/metrics/_otel.py, which nothing imported. (#544) - ♻️ Return
OTel | Nonefrom the private trace resolver so the handles narrow together, rather than a tuple of independently optional fields. (#540)
0.30.1 - 2026-07-18
Fixed
- 🐛 Use
inspect.iscoroutinefunctionin@cached, so grelmicro runs clean on Python 3.14, whereasyncio.iscoroutinefunctionis deprecated. (#532)
0.30.0 - 2026-07-18
Breaking
- 💥 Replace the idempotency
Operation.responseattribute with anOperation.result()method typed as the stored type, so the replay branch returns it without a cast. It is valid only on a replay: calling it on a first execution raises the newIdempotencyStateError. (#504)
Fixed
- 🏷️ Preserve the decorated function's type through
@health.check, so awaiting an async check directly type-checks without# type: ignore. (#499)
Internal
- ♻️ Use the standard library
uuid.uuid7()on Python 3.14+, so outbox ids stay monotonic within a millisecond. The vendored generator stays as the fallback for 3.12 and 3.13. (#522) - ✅ Treat warnings as errors in the test suite and close the FastAPI health test client cleanly. (#526)
- ⬆️ Adopt
httpx2in the test suite so Starlette'sTestClientstops warning about httpx v1. (#527)
0.29.5 - 2026-07-18
Security
- 🔒 Add a security policy with private vulnerability reporting. (#523)
Docs
- 📝 Add a Contributing section to the README linking issues and the contributing guide. (#523)
- 📝 Add an
llms.txtdocumentation index for LLM-friendly discovery. (#524)
Internal
- 👷 Pin the demo Docker image by digest and track it with Dependabot. (#523)
0.29.4 - 2026-07-18
Internal
- ⬆️ Bump the Python dependency group (including
redis8.0.1,pytest,ruff, andty0.0.58), the GitHub Actions, and the pre-commit hooks. (#514, #510, #488) - 🚨 Adapt to
ty0.0.58: type theComponentprotocol'snameas a read-only property and trim stalety: ignoredirectives.
Docs
- 📝 List the transactional outbox among the modules in the README intro.
0.29.3 - 2026-07-18
Features
- ✨ Read the Postgres database name from
POSTGRES_DATABASEtoo, not onlyPOSTGRES_DB, so the longer spelling works from the environment.DBstill wins when both are set. (#518)
0.29.2 - 2026-07-18
Features
- ✨ Let the outbox auto-purge delivered rows.
keep_deliverednow accepts atimedelta: the relay keeps delivered rows for that window and purges them in the background, so retention needs no scheduled job.Truestill keeps them for good andFalsestill deletes on delivery. - ✨ Add
Outbox.current()to resolve the app-registered outbox, so a producer canpublishwithout holding the instance or a config-bound singleton. (#517) - ✨ Add
PostgresOutboxAdapter.create_table_sql()anddrop_table_sql(), the exact DDLauto_migrateruns, so Alembic and other migration tools own the outbox schema withauto_migrate=False.
Docs
- 📝 List the outbox in the README module table and align the outbox guide with the other module docs.
0.29.1 - 2026-07-18
Features
- ✨ Add
command_timeouttoPostgresProvider(kwarg orPOSTGRES_COMMAND_TIMEOUT) so a query against a frozen or unreachable Postgres raisesTimeoutErrorin bounded time instead of hanging until the OS TCP timeout. - ✨ Add the
outboxmodule: a PostgreSQL transactional outbox that runs any async handler exactly after your transaction commits, at least once.publishstages a message in your own asyncpg, SQLAlchemy, or SQLModel transaction, and a relay delivers it withFOR UPDATE SKIP LOCKED,NOTIFYwakeups, a visibility lease, retries with backoff, and dead-lettering. Trace context and delivery metrics ride the trace and metrics components, andpurgetrims delivered and dead rows. Backend-first, so SQLite and MySQL can follow.
0.28.2 - 2026-07-05
Fixed
- 🐛 Make idempotency storage atomic. The fingerprint guard is now written before the response and outlives it, so a failed guard write can never leave a response that a different payload could replay. (#494)
- 🐛 Guard the process-global active-app registry with a lock so two apps starting concurrently cannot both enter and clobber each other's
Log/Trace/Metricsstate. The second raisesMultipleActiveAppsError. (#494) - 🐛 Roll back an opened
Grelmicroapp when a later FastStream startup hook fails, so a failed startup no longer leaves the app open and registered. (#494) - 🐛 Export
SQLiteCircuitBreakerAdapterfromgrelmicro.resilience, matching the other circuit breaker adapters. (#494)
Security
- 🔒 Make the
TaskLocktoken nonce unpredictable (a process-local counter joined with random bytes) so an untrusted in-process caller cannot forge another handle's ownership token. (#494)
Docs
- 📝 Point every ambient-miss
OutOfContextErroratmicro.install(app), and switch the README and first-steps examples to the one-callmicro.install(app)form. (#494) - 📝 Add a first-use mental model, an operator defaults reference, an API conventions page, an adapter import policy, and decision tables for rate limiter methods and task entry points. (#494)
- 📝 Warn that
/healthzalways returns each check'serrorstring, and that cache keys and tags derived from untrusted input must stay bounded. (#494)
0.28.1 - 2026-07-05
Features
- ✨ Make
Tracea true no-op when the exporter auto-disables. With the defaultautoexporter and no endpoint,Traceinstalls no tracer provider, leaves the global untouched, runs no auto-instrumentation, and no longer counts against the single-active-app guard, soTrace()is safe to register unconditionally in dev, test, and CI. An explicitexporter=nonestill installs the provider. (#487)
Fixed
- 🐛 Block a second app that installs
Metricswhile one is active, matchingLogandTrace.Metricsowns the process-global meter provider, so two overlapping apps would clobber it.
Docs
- 📝 Refresh stale docs: the optional-extras table, the capability matrix, the module lists, the
@cachedlockdefault, and theDuplicateFilterttlfield.
0.28.0 - 2026-06-28
Breaking
- 💥 Rename the recurring-task decorator from
@task.interval(seconds=...)to@task.every(seconds=...), pairing it with@task.cron(...)as a verb-form family. Theseconds=keyword is unchanged. Update your call sites. - 💥 Move the framework integration modules into
grelmicro.integrations.grelmicro.fastapibecomesgrelmicro.integrations.fastapi, soGrelmicroMiddlewareis nowfrom grelmicro.integrations.fastapi import GrelmicroMiddleware, and the new FastStream wiring lives atgrelmicro.integrations.faststream. - 💥 Move the FastAPI health router from
grelmicro.health.fastapitogrelmicro.integrations.fastapi, so all FastAPI integration code (middleware, install, health router) lives undergrelmicro.integrations. Updatefrom grelmicro.health.fastapi import health_routertofrom grelmicro.integrations.fastapi import health_router. - 💥 Collapse the four
@task.everylock passthroughs into one typedlock=TaskLock(...). Thelease_duration,min_hold_duration,backend, andworkerparameters are removed. Pass aTaskLockinstead, like@task.every(seconds=60, lock=TaskLock(lease_duration=300)). The lock keeps its default"default"name and is re-stamped to the task name, so you never repeat it. The lock's settings are authoritative.leader=andsync=stay separate, self-documenting parameters. - 💥
HealthChecksnow namespaces its env vars per instance: the default instance keepsGREL_HEALTH_*, but a named instance readsGREL_HEALTH_{NAME_UPPER}_*(matchingLock,CircuitBreaker, and the other named components). Update env vars for any namedHealthChecks. - 💥 The log filters now namespace env vars per instance.
GREL_DUPLICATE_FILTER_*becomesGREL_DUPLICATEFILTER_*andGREL_RATE_LIMIT_FILTER_*becomesGREL_RATELIMITFILTER_*, with a newenv_name=kwarg for a named instance (GREL_DUPLICATEFILTER_{NAME}_*).DuplicateFilter.key_modealso gainslogger,level, andglobalso both filters share one vocabulary. Update env vars andenv_prefix=overrides. - 💥 Replace the six manual circuit-breaker control methods with two operator verbs.
isolate()forces the breaker open until reset,reset()returns it to normal automatic operation startingCLOSED. Replacetransition_to_forced_open()withisolate()andrestart()withreset(). The remainingtransition_to_closed,transition_to_open,transition_to_half_open, andtransition_to_forced_closedare removed. - 💥
CircuitBreakerMetricsandErrorDetailsare now frozen slotted dataclasses instead of Pydantic models. Attribute access is unchanged, but they no longer offer.model_dump()or Pydantic validation. This matchesCircuitBreakerSnapshotandCacheInfo(read-models are dataclasses, Pydantic is reserved for serialization boundaries). - 💥
@cachednow folds concurrent misses of the same key in-process by default (lock="local"instead oflock=False). This removes a silent thundering-herd footgun at no I/O cost. Passlock=Falseto restore the old behavior where every concurrent miss recomputes. - 💥 Move the backend protocols out of the public
abcsubmodules into private_protocolmodules (clock,config,coordination,task), matchingcacheandresilience.VirtualClocklikewise moves toclock/_virtual.py. The protocols stay exported from each package, so import them from the package (from grelmicro.coordination import LockBackend) instead ofgrelmicro.coordination.abc. - 💥
reconfigure()now raisesCoordinationSettingsValidationError(aSettingsValidationError, still aValueErrorsubclass) instead of a bareValueErrorwhen a new config would change the immutableworker. CatchSettingsValidationErrororGrelmicroErrorto handle it. - 💥 Rename the
ExternalConfigintervalparameter toreload_interval, tying the knob to thereload()verb it controls. UpdateExternalConfig(interval=...)toreload_interval=. - 💥 Rename the log dedup
ttl_secondsfield tottl, matching the bare-noun duration convention used everywhere else (the cachettl, lock durations). UpdateDuplicateFilter(ttl_seconds=...)andDuplicateFilterConfigtottl=. - 💥 Rename the
Tracecomponent symbols to theTracestem so they match the component name.TracingConfig,TracingError,TracingExporterType,TracingProcessorType,TracingSamplerType, andTracingSettingsValidationErrorbecomeTraceConfig,TraceError,TraceExporterType,TraceProcessorType,TraceSamplerType, andTraceSettingsValidationError. Update imports. - 💥 Rename the
Logcomponent symbols to theLogstem so they match the component name.LoggingConfig,LoggingError,LoggingBackendType,LoggingFormatType,LoggingLevelType,LoggingSerializerType,LoggingTimeZoneType, andLoggingSettingsValidationErrorbecomeLogConfig,LogError,LogBackendType,LogFormatType,LogLevelType,LogSerializerType,LogTimeZoneType, andLogSettingsValidationError. Update imports. - 💥 Rename the
TaskLocklease boundaries to lease-anchored names, matchingLockConfigand the KubernetesLeaseDuration.max_lock_secondsbecomeslease_durationandmin_lock_secondsbecomesmin_hold_duration. This covers theTaskLockConfigfields and theTaskLockconstructor. UpdateTaskLock(max_lock_seconds=..., min_lock_seconds=...)tolease_duration=..., min_hold_duration=...and the env varsGREL_TASKLOCK_{NAME_UPPER}_MAX_LOCK_SECONDSand_MIN_LOCK_SECONDSto_LEASE_DURATIONand_MIN_HOLD_DURATION. - 💥 Rename the concrete leader election adapters to the
*Adaptersuffix every other pattern already uses.MemoryLeaderElectionBackend,RedisLeaderElectionBackend,PostgresLeaderElectionBackend, andKubernetesLeaderElectionBackendbecomeMemoryLeaderElectionAdapter,RedisLeaderElectionAdapter,PostgresLeaderElectionAdapter, andKubernetesLeaderElectionAdapter. TheLeaderElectionBackendprotocol keeps its name (protocol stays*Backend, concrete stays*Adapter). Update direct imports and constructions. - 💥 Rename the resilience component wrappers to the singular
*Registryform, matching their singularkindand theCoordinationandCachesiblings.RateLimitersbecomesRateLimiterRegistryandCircuitBreakersbecomesCircuitBreakerRegistry. Updateuses=[...]and imports. - 💥 Drop the redundant
DEFAULTsegment from the default instance env prefix. A default instance (Lock("default"),Retry.exponential("default"), and the like) now reads the bareGREL_{COMPONENT}_{FIELD}instead ofGREL_{COMPONENT}_DEFAULT_{FIELD}. Rename env vars likeGREL_LOCK_DEFAULT_LEASE_DURATIONtoGREL_LOCK_LEASE_DURATION. Named instances are unchanged. The default instance now owns the bareGREL_{COMPONENT}_namespace, so name your other instances to avoid clashing with a field name (aLock("lease")would shareGREL_LOCK_LEASE_DURATIONwith the default instance). - 💥 Raise
OutOfContextErrorwith an actionable message on every ambient backend miss:Lock,TaskLock,LeaderElection,TTLCache,@cached, the cron schedule resolution, andIdempotencynow matchCircuitBreakerandRateLimiter.NoActiveAppErrorstays the low-level error raised byGrelmicro.current()itself. - 💥 Remove the implicit memory fallback on
CircuitBreakerandRateLimiter. Backend resolution is now one rule on every pattern: explicitbackend=wins, else the active app's component, elseOutOfContextError. For a per-process limiter or breaker without an app, passbackend=MemoryRateLimiterAdapter()orbackend=MemoryCircuitBreakerAdapter()(both import fromgrelmicro.resilience). Inside FastAPI handlers, addGrelmicroMiddlewareso ambient resolution works there.RateLimiter.reconfigurenow publishes the config and rebinds the strategy lazily on the next call, matchingCircuitBreaker. - 💥 Add positional argument capture to
grelmicro.testing:Callis nowCall(method, args=..., kwargs=...)andCallLog.countmatches positional arguments too. Update directCall(...)constructions. - 💥 Align the leader election and task lock env var prefixes with their single-token names:
GREL_LEADER_ELECTION_{NAME}_becomesGREL_LEADERELECTION_{NAME}_andGREL_TASK_LOCK_{NAME}_becomesGREL_TASKLOCK_{NAME}_. Update any environment variables set for these components. PR #346. - 💥 Rename the pattern factory methods so each uses the pattern's single-token name:
Provider.breaker()becomescircuitbreaker(),leader_election()becomesleaderelection()on bothProviderandCoordination, andCoordination.task_lock()becomestasklock(). This matches the module names (grelmicro.coordination.leaderelection,grelmicro.coordination.tasklock,grelmicro.resilience.circuitbreaker) and theratelimiter/circuitbreakerkind strings. Updateprovider.circuitbreaker(),micro.coordination.leaderelection(...), andmicro.coordination.tasklock(...)call sites. PR #343, PR #344. - 💥 Make
Log,Trace, andMetricssingletons. Each configures process-global state (the root logger, the OpenTelemetry tracer and meter providers), so registering a second one on the same app now raisesComponentAlreadyRegisteredErrorinstead of silently clobbering the first. PR #343. - 💥 Make the component
namea read-only property everywhere (Coordination,Cache,Log,Trace,Metrics,RateLimiters,CircuitBreakers,HealthChecks,RealClock,VirtualClock), matching the resilience and coordination primitives. Passname=at construction. PR #343.
Features
- ✨ Trace FastStream messages.
micro.install(faststream_app)now wires the broker's OpenTelemetry telemetry middleware against the app's tracer, so consumed and published messages get spans with no per-handler decoration. Selected by the sameTrace(instrument=...)directive under thefaststreamname, and a no-op when the broker's faststream telemetry support is not installed. (#470) - ✨ Trace Valkey commands. grelmicro ships a first-party
ValkeyInstrumentor(valkey-py has no official OpenTelemetry package), registered as a standardopentelemetry_instrumentorentry point soTrace(instrument=...)discovers it like any other library, andValkeyProvideruses it. It reuses the Redis span factories against thevalkey.*classes, so Valkey spans match the Redis ones. (#479) - ✨ Trace any library the app uses, not just grelmicro-managed providers.
Trace(instrument=True)now sweeps every installedopentelemetry-instrumentation-*package and attaches it to the app's tracer, so an app's own SQLAlchemy or asyncpg engine, httpx client, and the like are traced with no grelmicro provider. The set of installed instrumentors defines coverage (no new hard dependency), names follow the OpenTelemetry instrumentor names, and the asyncpg/SQLAlchemy pair is de-duplicated to avoid double spans. (#479) - ✨ Fail fast on a missing ambient binding.
micro.install(app, ambient=False)now warns at startup when ambient-resolving components are registered (it raisesAmbientBindingErrorunderGrelmicro(strict=True)), and the newmicro.check_ambient_binding(app)returns whether the binding middleware is wired so a test can catch a forgottenmicro.install(app)before it 500s on the first request. (#471) - ✨ Auto-disable
Traceuntil an endpoint is configured. The exporter now defaults toTraceExporterType.AUTO, which exports over OTLP HTTP when an endpoint is set (theendpointargument,OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, orOTEL_EXPORTER_OTLP_ENDPOINT) and no-ops otherwise. RegisterTrace()unconditionally and it stays silent in dev, test, and CI instead of falling back tolocalhost:4318. (#476) - ✨ Add first-class HTTP Basic auth to
Trace. Passbasic_auth=(username, password)or setGREL_TRACE_BASIC_AUTH_USERNAMEandGREL_TRACE_BASIC_AUTH_PASSWORD, and theAuthorization: Basicheader is built and attached to the exporter directly, bypassing the fragileOTEL_EXPORTER_OTLP_HEADERSencoding. (#476) - ✨ Add native auto-instrumentation to
Trace.Trace(instrument=...)traces incoming FastAPI requests and Redis and Postgres calls against the app's tracer provider, no per-handler decoration. On by default, a no-op until the newinstrumentationextra is installed. PassFalse, a name or list to select, or a{name: False}map to exclude. Redis attaches per-client, asyncpg is patched process-wide, and Valkey and SQLite stay on@instrument. - ✨ Add
RateLimiter.wait(), a blocking admission verb that waits until tokens are available then consumes them. It polls on the clock seam (soVirtualClockdrives it in tests), waits as long as needed by default, and raisesRateLimitExceededErroronce an optionalmax_waitbudget is exceeded. Acostlarger than the limit raisesValueErrorinstead of waiting forever. - ✨ Add
LeaderElection.lead(func, *, repeat=False), which runs a coroutine only while the worker holds leadership and cancels it the instant leadership is lost, so no stale work outlives the lease. It returns the body's result if it finishes while still leader, orNoneif cancelled. Passrepeat=Trueto re-run after re-acquiring leadership. - ✨ Add
micro.install(app), one call that wires the lifecycle and per-handler ambient binding for Starlette, FastAPI, and FastStream. Passambient=Falseto skip the binding. - ✨ Accept a
timedeltafor the intervalseconds=, like@task.every(seconds=timedelta(minutes=2)). A plain number of seconds still works. - ✨ Add a
key=template to@cachedfor a stable, readable cache key rendered from the arguments, like@cached(key="user:{user_id}"). Passkey_maker=for the fully dynamic case. Passing both raisesTypeError. - ✨ Type
FireInfo.outcomeas the newFireOutcomeStrEnum(SUCCESS,ERROR,SKIPPED). String comparisons likeoutcome == "success"still work. - ✨ Add
Idempotency.run(key, factory), a one-call helper that runs an operation once and replays its response. It takes a sync or async factory and mirrorsTTLCache.get_or_set. - ✨ Every component now raises a typed
*SettingsValidationErrorfor invalid configuration, rooted in the sharedSettingsValidationErrorbase. AddsTraceSettingsValidationError,HealthSettingsValidationError,LogSettingsValidationError, andIdempotencySettingsValidationError. CatchSettingsValidationErrorto handle any of them. - ✨ Cache adapters (
MemoryCacheAdapter,RedisCacheAdapter,PostgresCacheAdapter,SQLiteCacheAdapter) now declare theCacheBackendprotocol explicitly, matching the lock, circuit breaker, and rate limiter adapters. - ✨ Add
Log.from_config,Trace.from_config, andMetrics.from_configto build each component from a pre-built config, matching the declarative path on every other pattern. Theconfig=kwarg still works. - ✨ Add
MemoryProviderso Memory has the same provider-direct surface (memory.lock(),memory.cache(), ...) as Redis, Postgres, and SQLite. - ✨ Add a built-in readiness check per provider. Every connection provider ships a cheap
check()probe (Redis and ValkeyPING, Postgres and SQLiteSELECT 1). Register it withhealth.add_provider(redis)as a criticalprovider:redischeck, or register one for every active provider at once withHealthChecks(auto_health=True).Grelmicro.providerslists the active providers. - ✨ Default the rate limiter
keyto"default"onacquire,acquire_or_raise,allow,peek, andreset, so the single-bucket case isawait limiter.allow(). The limiternamealready namespaces the backend key. - ✨ Add the zero-object
@cached(ttl=30)form for plain memoization: it binds a private process-localTTLCacheat decoration, never resolves the active app, and never shares across replicas. Pass aTTLCachefor shared state. Passing bothcacheandttlraisesTypeError. - ✨ Add the OpenSSF Scorecard workflow and badge.
- ✨ Make
Grelmicro(uses=[...])andmicro.use(...)forgiving: a bare Component class is constructed for you, a bare adapter (class or instance) is wrapped in its matching Component, and a bare Provider with no Components auto-registers one default Component per kind it serves. The explicit form always wins, so any explicit Component turns provider auto-registration off entirely. - ✨ Add
AmbiguousProviderError, raised whenuses=[...]lists two bare Providers with no Components, so the default Component for a shared kind would be ambiguous. Wrap each Provider in the Components it should serve to resolve it. - ✨ Add the Idempotency pattern: a new
grelmicro.idempotencymodule with anIdempotencyprimitive and@idempotentdecorator. A caller-provided key (anIdempotency-Keyheader) executes the operation once, stores the response forttlseconds, and replays it on repeats. Duplicates arriving mid-flight fold into the first execution, across replicas when a Coordination lock backend is configured. A failure stores nothing, so a retry executes fresh. An optionalfingerprint=rejects a reused key with a different payload viaIdempotencyConflictError. Storage rides the cache layer (cache=or the active app'sCachecomponent). - ✨ Add Redis Sentinel and Redis Cluster support:
redis+sentinel://host1:26379,host2:26379/serviceandredis+cluster://host1,host2URL schemes onRedisProvider, plusRedisProvider.sentinel(...)andRedisProvider.cluster(...)factories, so one URL switches topology. On Cluster, the multi-key cache and lock operations require a hash-tagged prefix (prefix="{app}cache"), enforced with a clear error at construction. - ✨ Add Valkey support: a
ValkeyProvideringrelmicro.providers.valkey(extravalkey) serves the full Redis adapter column (Lock, TaskLock, LeaderElection, Schedule, TTLCache, RateLimiter, CircuitBreaker) through thevalkeyclient. - ✨ Add the Externalized Configuration pattern: a new
grelmicro.configmodule with anExternalConfigcomponent that reconfigures live components from a mounted ConfigMap, Secret,.env, JSON, YAML, or TOML file (FileConfigAdapter, nested mappings flatten to env-style keys), polling on an interval with a publicreload()for an immediate pass. Sources are pluggable via theConfigBackendprotocol. Every named pattern built imperatively registers under itsGREL_{PATTERN}_{NAME}_keys, includingCircuitBreaker(GREL_CIRCUITBREAKER_{NAME}_) andRateLimiter(GREL_RATELIMITER_{NAME}_). Instances built from a pre-built config stay static. Validation warnings log field names only, never values. - ✨ Add
GrelmicroMiddlewareingrelmicro.fastapi: a pure ASGI middleware that binds the active app inside request handlers, soLock("cart"),RateLimiter.sliding_window(...), and@cachedresolve ambiently in handlers without explicitbackend=wiring. - ✨ Add a bounded wait to
Lock.acquire(timeout=), raisingTimeoutErrorat the deadline, andLock.extend()to renew the lease of a held lock without releasing it. Both are mirrored on thefrom_threadfacade. - ✨ Add
TaskLock.refresh()so a task body that may outrunmax_lock_secondscan renew its claim, raisingLockNotOwnedErrorwhen the claim was lost. - ✨ Add
retry_jittertoLockandLeaderElection(default 0.1): each retry sleepsretry_interval * uniform(1 - jitter, 1 + jitter), so contending workers spread their attempts instead of retrying in lockstep. - ✨ Add scheduler introspection:
next_fire_timeandlast_fireon interval and cron tasks, withFireInfo(started_at, outcome, duration) exported fromgrelmicro.task. - ✨ Add
Match.explain()returning the human-readable matcher tree, and warn once when a predicate returns a non-bool value. - ✨ Add a shared
AdmissionErrorbase so every gatekeeping rejection is catchable with oneexcept.RateLimitExceededError,BulkheadFullError,CircuitBreakerError, andWouldBlockErrornow inherit it, soexcept AdmissionErrorhandles a rate limiter over budget, a full bulkhead, an open circuit breaker, or a non-blocking lock that would block. It is purely additive: the existing per-primitiveexceptclauses still work. PR #354. - ✨ Add
RateLimiter.allow(key=...)returning aboolfor the common served-or-throttled branch, and makeRateLimitResulttruthy (bool(result)isresult.allowed).if await limiter.acquire(key=...):now reads as the decision whileretry_afterandremainingstay available on the result. PR #354. - ✨ Add serve-stale-on-error to the cache with
stale_ttl=on@cached,get_or_set, andTTLCache.set. Each value keeps a fallback copy forttl + stale_ttlseconds, so a recompute that fails after the TTL serves the last good value instead of raising, up tostale_ttlseconds late. A flaky upstream degrades to slightly stale data instead of an error storm. It composes withlockandearly, an explicit delete or tag invalidation drops the fallback, and each stale serve records thegrelmicro.cache.stale_servesmetric. PR #350. - ✨ Add SQLite cache and circuit breaker backends, completing the SQLite column of the capability matrix (the circuit breaker coordinates single-host multi-process state). PR #349.
- ✨ Add a durable
@tasks.cron(expr, timezone="UTC")decorator that runs a task on a 5-field cron schedule (minute hour day-of-month month day-of-week). The parser is built in, with no external dependency, and supports*, steps, ranges, lists, and the7-as-Sunday alias. It uses standard Vixie day-of-month/day-of-week OR semantics. Each fire is claimed against a durable last-fire state via a newScheduleBackend(Memory, Redis, Postgres, and SQLite), so the task runs at most once across all workers per fire. A fire missed while every worker was down replays once on restart, bounded bymisfire_grace_seconds, and only the most recent missed fire runs. Wire it viaCoordination(provider)orCoordination(schedule=...). PR #348. - ✨ Add a time-based stop to
Retrywithmax_seconds=. Retrying stops as soon as eitherattemptsis reached or the wall-clock budget elapses, whichever comes first (attemptsstill defaults to 3). Available on theRetry.exponential/Retry.constantfactories, the constructor, andRetryConfig(env varGREL_RETRY_{NAME}_MAX_SECONDS). The budget reads the clock seam, soVirtualClockdrives it in tests. PR #347. - ✨ Re-export
FunctionTypeErrorandTaskAddOperationErrorfromgrelmicro.task, so the task errors users catch live next toTaskErrorinstead of only ingrelmicro.task.errors. PR #343. - ✨ Export the catch-all base
GrelmicroErrorand the cross-cuttingDependencyNotFoundError,OutOfContextError, andSettingsValidationErrorfrom the top-levelgrelmicropackage, soexcept GrelmicroErrorcatches any library error from one import. Re-exportWouldBlockErrorandCoordinationBackendErrorfromgrelmicro.coordination(the latter moved intogrelmicro.coordination.errors). PR #343.
Fixed
- 🐛 Name the failing source in the
ExternalConfigreload warning, so a broken config or secrets mount is no longer a generic warning. Each source loads under its own guard, so a config failure no longer hides a working secrets source. Source values are never logged. - 🐛 Raise an actionable
RuntimeErrorfrom a sync@cachedcall when the backend never captured a running loop, instead of an opaqueAttributeError. The message says to open the backend withasync with micro:first. - 🐛 Reconcile cache tags on every Redis
setandset_many, even with no tags. Re-setting a previously tagged key without tags now drops its stale tag membership, so a laterdelete_tagsno longer wrongly removes it. PR #353. - 🐛 Store the cache sidecar entries (the
early=refresh metadata, and the new stale reserve) under a\x1fseparator instead of\x00, so they are valid Postgres text keys.@cached(early=...)previously raised on a Postgres cache backend. PR #350.
Docs
- 📝 Add
docs/architecture/decorators.mddocumenting the bare@decoversus parametrized@deco(...)rule and which decorators wrap sync functions. PR #343.
0.27.0 - 2026-06-07
Breaking
- 💥 Replace
@cached(stampede="local" | "distributed" | None)with@cached(lock=False | True | "local").lock=Truefolds concurrent misses and picks the cross-replica path automatically when the active app has aCoordinationlock backend (in-process otherwise),lock="local"forces the in-process path, and the default is nowlock=False(no protection, opt in explicitly). Migratestampede="local"tolock="local",stampede="distributed"tolock=True, andstampede=Nonetolock=False. Issue #235. - 💥 Move
LeaderElectionout ofgrelmicro.syncinto a newgrelmicro.coordinationpackage. Import it fromgrelmicro.coordination.Sync.leader_election()is removed: register aCoordinationcomponent and callmicro.coordination.leader_election(...). Leader election now runs on a dedicatedLeaderElectionBackend, not the lockSyncBackend, so it can use a different vendor thanLock(Redis forLock, a Kubernetes Lease for leader election). Issue #223. - 💥 Unify
grelmicro.syncintogrelmicro.coordinationand deletegrelmicro.sync. ImportLock,TaskLock,LeaderElection, andCoordinationfromgrelmicro.coordination. TheSynccomponent is gone: use oneCoordinationcomponent, which exposes.lock(...),.task_lock(...), and.leader_election(...), and reach it onmicro.coordination. TheSyncBackendprotocol is nowLockBackend, the*SyncAdapterbackends are now*LockAdapter, and the provider factory.sync()is renamed to.lock(). Issue #223. - 💥 Make the JSON utilities internal. The
grelmicro.jsonmodule is removed. UseJsonSerializerfromgrelmicro.cachefor cache JSON, ororjsondirectly if you need raw fast JSON.
Features
- ✨ Add a
Metricscomponent that installs an OpenTelemetryMeterProviderfor the app's lifetime, with OTLP, Prometheus, console, and none exporters. A@measuredecorator times and counts any function,metrics_router()serves a Prometheus/metricsendpoint, and every built-in component (health, circuit breaker, retry, rate limiter, bulkhead, timeout, cache, tasks) emits its own metrics. All metric calls are no-ops without theopentelemetryextra or an active component. - ✨ Leader election leases carry a Kubernetes-style
LeaderRecord(holder, lease duration, acquire and renew times, leadership transitions, and free-form metadata). Read it fromLeaderElection.record, set the metadata viaLeaderElection(metadata=...). Metadata-storing backends ship for memory, Redis, Postgres, and Kubernetes Lease, resolved throughprovider.leader_election()or passed toCoordination(...)directly. Issue #223. - ✨ Add
grelmicro.testing.record(backend)for protocol-level call assertions. It instruments a backend's public async methods in place and returns aCallLog, so the backend keeps its type and behavior while every call is recorded. Assert withlog.count(method, **kwargs), inspectlog.methods(), or read the rawlog.calls. Works likepytest-mock'smocker.spy. Issue #271. - ✨ Add cache tags,
get_or_set, and batch operations. Tag entries viaset,set_many,get_or_set, or@cached(tags=["users", "user:{user_id}"]), then invalidate a whole group withdelete_tags.get_or_set(key, factory)computes a missing value once under the same stampede protection as@cached(lock=True).get_many,set_many, anddelete_manywork on many keys at once. Tags and batch ops run on Memory, Redis, and Postgres. - 📝 Correct the comparison page and capability matrix to show the Postgres and SQLite cache, rate limiter, and circuit breaker backends as shipped (they were stale-labeled "planned").
- 📝 Add a "what grelmicro is not" line to the README and docs landing for sharper first-read positioning.
- 🔧 Set the PyPI
Development Statusclassifier to4 - Beta. - ✨ Discover Providers and Adapters through entry-point groups. Third-party packages register under
grelmicro.providersandgrelmicro.{kind}.adapters(coordination,cache,ratelimiter,circuitbreaker) and resolve by short name, the same path first-party backends use. Unknown names raiseProviderNotRegisteredErrororAdapterNotRegisteredErrorlisting the installed names. Newdocs/architecture/plugins.mdand anexamples/third-party-adapter/skeleton. Issue #234. - ✨ Add
VirtualClockfor deterministic time in tests. Time-dependent primitives (Retrybackoff,CircuitBreakerhalf-open window,RateLimiterrefill,Shieldadaptive gate) read time through a clock seam (grelmicro.clock.monotonic/sleep). Install aVirtualClock(Grelmicro(uses=[clock, ...])orasync with VirtualClock()) and callclock.advance(seconds)to drive that behavior with no real waiting. With no clock registered, the seam forwards totime.monotonicandasyncio.sleep, so production keeps real time. Issue #272. - ✨ Auto-discover shared Providers in
Grelmicro(uses=[...]). A Provider held by a Component (Coordination(redis),Cache(redis)) no longer has to be listed separately: it is adopted and lifecycled exactly once, opened before the Components that hold it. Listing it explicitly stays valid and keeps control over lifecycle order. Issue #263.
Docs
- 📝 Lead every feature page with the simplest runnable example, then explain, moving deep theory into collapsible sections. Covers the resilience patterns and the cache, coordination, logging, health, tracing, and task guides.
0.26.0 - 2026-06-05
Breaking
- 💥 The
Taskprotocol's__call__now takes astop: asyncio.Event | None = Nonekeyword used for graceful shutdown. CustomTaskimplementations must accept it. The built-inintervaltasks andLeaderElectionare unaffected. Issue #187. - 💥 Replace the
@cached(lock=...)parameter with@cached(stampede="local" | "distributed" | None).lock=Truebecomesstampede="local"(now the default),lock=Falsebecomesstampede=None, and the custom-context-manager form is dropped in favor of the"distributed"cross-replica mode. Issue #235.
Features
- 📝 Add a runnable FastAPI demo under
examples/fastapi-demo/.docker compose up --waitstarts Redis, Postgres, and a FastAPI app that exercises every Pattern (cache, rate limiter, circuit breaker, distributed lock, leader-gated task, health probes), with aDemo SmokeCI job and ajust demoshortcut. Issue #166. - 📝 Add a ConfigMap-watcher example wiring
reconfigure()(docs/configuration/reconfigure-from-configmap.md), with aSIGHUPvariant for non-Kubernetes hosts. Issue #169. - ✨ Accept bare zero-arg classes in
Grelmicro(uses=[...]),micro.use(...), and theSync/Cache/RateLimiters/CircuitBreakersconstructors.uses=[MemorySyncAdapter]andSync(MemorySyncAdapter)now work without the trailing(), in the spirit of FastAPI'sDepends(dep). A class that needs constructor arguments raises a clear error. Issue #263. - ✨ Guard against two overlapping
Grelmicroapps clobbering process-global state. Opening a second app that registersLogorTracewhile another such app is active now raisesMultipleActiveAppsError. Apps without those components overlap freely. PassGrelmicro(allow_multiple=True)to opt out. Newdocs/architecture/multiple-apps.mddocuments the policy. Issue #266. - ✨ Add
Tasks(shutdown_timeout=...)for graceful shutdown. On exit,Taskssignals everyintervaltask to finish its current run and stop, force-cancelling only stragglers that outlast the timeout. The default30.0matches Kubernetes'terminationGracePeriodSeconds, andLeaderElectionreleases leadership on the same signal. Newdocs/architecture/graceful-shutdown.mdcovers signal wiring. Issue #187. - ✨ Add a three-layer cache stampede menu to
@cached.stampede="local"(default) folds concurrent same-key misses to one in-process run,stampede="distributed"coordinates across replicas through theSynccomponent, andearly=(XFetch) refreshes the hottest keys in the background before they expire so no caller blocks. Issue #235. - ✨ Add
LeaderElection.last_confirmation_age()(seconds since the last backend response that confirmed local leadership,Noneuntil first acquisition and after confirmed loss) andLeaderElection.is_leader_confirmed_within(max_age)(stricter variant ofis_leader()that requires a recent backend renewal). Theis_leader()docstring now spells out the advisory uncertainty window during a backend partition. - ✨ Add
Grelmicro(strict=True)to raiseLifecycleOrderErrorinstead of warning when a Component holds a Provider that is missing fromuses=or listed after the dependent Component. The defaultFalsepreserves the lenient warn-only behavior.LifecycleOrderErroris exported fromgrelmicro. - ✨ Add
Shieldresilience pattern: per-attempt timeout, retry-budget-gated retries, CUBIC-style adaptive rate limiter, optional cache and fallback recovery paths. Three profiles (internal,api,slow) cover the common cases. Decorator (@shield,@shield.api(...)), class (Shield.api("name")), and imperative (Shield.api("name").run(fn, ...)) forms supported. Issue #249. - ✨ Add
TTLCacheConfigand expose it viaTTLCache.config. Matches the frozen-config shape used by every other primitive. - ✨ Add
RedisProvider.safe_urlandPostgresProvider.safe_urlreturning the resolved URL with the password replaced by***. The new__repr__on both providers uses the safe form so credentials never leak through logs or tracebacks. - ✨ Add
TracingConfig.shutdown_timeout(default5.0seconds).Trace.__aexit__now runsTracerProvider.shutdown()in a thread with this deadline so a slow or broken exporter no longer hangs application shutdown. - ✨ Add
SQLiteProviderand SQLite rate limiting. UseRateLimiters(SQLiteProvider("app.db"))for file-backed limits on a single host. Each acquire runs a read-modify-write inside aBEGIN IMMEDIATEtransaction. Issue #173. - ✨ Add
PostgresCircuitBreakerAdapterfor fleet-wide circuit breaker state on Postgres, plusPostgresProvider.breaker()soCircuitBreakers(postgres)resolves it. Transitions run in PL/pgSQL functions guarded bypg_advisory_xact_lock. - ✨ Add
Bulkheadresilience pattern to cap concurrent in-flight calls.max_concurrentbounds concurrency,max_waitlets callers queue briefly before aBulkheadFullError(default fails fast), andmax_workersruns blocking work throughbulkhead.to_threadon a dedicated pool. Async context manager and decorator forms. Issue #168. - ✨ Add
Bulkhead(uses=[...])to scope Providers and Components to a bulkhead. Inside the scope, a Pattern resolving its default backend picks up the bulkhead's Component, isolating a business context onto its own pool. Explicitbackend=still wins. Issue #168.
Fixes
- 🐛 The README and
simple_fastapi_app.pyFastAPI examples now pass an explicitbackend=to patterns used inside request handlers. Request handlers run outside the app's ambientGrelmicro.current()scope, so the previous ambient form raisedNoActiveAppError(locks, cache) or silently fell back to an in-memory backend (rate limiter, circuit breaker) at runtime. BackgroundTaskskeep using ambient resolution. Ambient resolution in handlers is tracked in #328. - 🔒
SettingsValidationErrorno longer echoes the offending input value. Env-loaded credentials (DSNs, tokens) no longer surface in error messages. - 🚨
ComponentNotRegisteredErrorfromGrelmicro.get(kind, name)now lists every registered(kind, name)pair (or states that none are registered). Agents and developers see what is available without inspecting the container. - 🚨
HealthChecks.addinvalid-name errors now include valid examples ('redis','db-primary','weather:circuitbreaker') alongside the regex. - 🐛
Log.__aenter__andLog.__aexit__now serialize on a class-levelthreading.Lockso concurrentGrelmicrolifecycles in the same process cannot interleave the stdlib root-logger snapshot / restore sequence. - 🐛 Unexpected exceptions inside a health check now surface as
"TypeName: message"in theCheckResult.errorfield instead of the generic"Health check failed". Operators reading only the/healthzpayload can identify the failing class without grepping logs. - 🔒
Lock("...")now validates the name against^[A-Za-z0-9][A-Za-z0-9._:/-]*$(max 200 chars). Names with whitespace, control characters, or leading separators are rejected with a message that includes valid examples. Existing namespaced names (users:42,payments/eu,weather.svc) keep working. - ⚡
DuplicateFilternow sweeps entries older thanttl_secondsonce per window, so high-cardinality log floods stop evicting still-active keys by size pressure.
Docs
- 📝 Lead
README.mdanddocs/index.mdwith a one-route, one-primitive FastAPI example before the full composition demo. - 📝 Annotate
Grelmicro.use,Grelmicro.get,instrument, andCacheBackendprotocol parameters withAnnotated[..., Doc(...)]. - 📝 Align the
CONTRIBUTING.mddiscriminator rule with the code:kind(nottype). - 📝 Document the per-process scope of
Tasksand point atTaskLock/LeaderElectionfor cluster-wide scheduling. - 📝 Add a Kubernetes operational-assumptions section covering RBAC, API server availability, etcd latency, and single-cluster scope to
docs/architecture/kubernetes.md. - 📝 Fix the
sync.md → task.md#tasksinternal anchor somkdocs --strictno longer reports it. - 📝 Add a lifespan-only example (one provider, one component) between the minimal example and the full composition demo in
README.mdanddocs/index.md. - 📝 Drop unsupported claims and idioms from the landing copy ("Stop reinventing the wheel", "battle-tested in production", "TL;DR").
- 📝 Add
Start here/Common recipeslead lines to every page underdocs/reference/. - 📝 Add an explicit
Running testssection toCONTRIBUTING.mdwith the commands for unit-only, integration-only, and the full local gate. - 📝 Add a
What should I pick?decision tree to the top ofdocs/comparison.mdso readers can map their situation to the right tool (one primitive, two or more, task queue, workflow engine, web framework). - 📝 Add a
Your first contributionsection toCONTRIBUTING.mdwith the expected code, test, and docs shape and a pointer to thegood first issuelabel. - 📝 Add
Annotated[..., Doc(...)]to theSyncBackend,RetryStrategy,RateLimiterStrategy,RateLimiterBackend,CircuitBreakerStrategy, andCircuitBreakerBackendprotocol parameters so IDE and LLM tools surface the same hints on backends as on user-facing primitives. - 📝 Group the
grelmicro.resiliencepackage docstring into front doors, components, adapters, and configs so import-site hover help guides agents and humans to the preferred entry point. - 📝 Document that auto-generated task references (
module:qualname) surface in logs, distributed lock keys, and metric labels. Suggest passing an explicitname=for sensitive workflows invalidate_and_generate_referenceand thedocs/task.mdInterval Task section. - 📝 Add a
Why Python 3.12section todocs/installation.mdlisting the language features (PEP 695,asyncio.timeout) that drive the floor, and note that CI runs the matrix on every advertised classifier (3.12, 3.13, 3.14). - 📝 Add a
Platformscolumn to the Optional extras table indocs/installation.mdcalling out thatuvloopis skipped on Windows and PyPy. - 📝 Document
RateLimitResult.remainingas an estimate for continuous-state algorithms (GCRA-based sliding window). Enforcement still uses exact state, so the nextacquiremay be denied even whenremaining > 0. - 📝 Add a FastStream resilience recipe (
docs/snippets/resilience/faststream.py) that uses a fleet-wide per-keyLockand a sliding-windowRateLimiterinside a Redis-broker subscriber. Linked fromdocs/resilience/index.md. - 📝 Formalize the
test_<component>_<scenario>_<expected_outcome>test-name shape inCONTRIBUTING.mdwith three concrete examples. - 📝 Add
docs/benchmarks.mdwith reproducible request-path benchmarks for the rate limiter, circuit breaker, cache, and lock, plus runnable scripts underbenchmarks/. - 📝 Add a
Choosing a backendguide to the sync, cache, rate limiter, and circuit breaker pages. - 📝 Expand
docs/json.mdwith supported types, the orjson fallback, and serializer boundaries. - 📝 Note that the default OTLP HTTP trace exporter expects a running collector in
docs/tracing.md, withCONSOLEandNONEfor local development.
Internal
- 🔒
@instrumentnow filters arguments whose names match common secret keywords (password,token,secret,api_key,authorization,cookie, ... matched case-insensitively) from both span attributes and log context. Pass extra names viaskip=for custom secret-bearing parameters. Unchanged for non-sensitive args. - 🔧 Replace the optional
orjsonredef-as-Any | Nonepattern ingrelmicro/_json.pywith try/except branches that define the dumps/loads functions in scope. The per-call# type: ignore[union-attr]directives are gone, andorjsonkeeps its real type from the stub package in the available branch. - 🚨
Trace.__aenter__now raisesTracingErrorifopentelemetry.trace._TRACER_PROVIDERis missing instead of silently no-op patching. A future OTel that drops the private global surfaces a clear error pointing at the workaround. An inline comment near the patch documents why the private attribute is required. - 🔒
PickleSerializerdocs upgraded to a Danger callout. Pickle is now framed as trusted in-process backends only, and the@cacheddecorator example leads withJsonSerializer. TheTTLCachedocstring lists Pydantic and JSON before Pickle. - 🔧 Comment why
_env_prefix=env_prefixneeds a type-ignore inRedisProviderandPostgresProvider(pydantic-settings runtime kwarg the stubs do not expose). - ⚡ Snapshot hot config fields (
cost,allowed_repetitions,ttl_seconds,cache_size) ontoRateLimitFilterandDuplicateFilterinstances during setup so the per-recordfilter()path reads plain attrs instead of walking the Pydantic config. - 🔧 Drop three unused
ty: ignoredirectives ingrelmicro/_json.py. - ⚡
@cached(lock=True)per-key lock dictionaries now bound their size with LRU eviction (1024 entries). High-cardinality miss-heavy workloads no longer accumulateasyncio.Lock/threading.Lockobjects indefinitely. Held locks are never evicted, so in-flight stampede protection is preserved. - 🔒
PostgresRateLimiterAdapteradvisory locks now usepg_advisory_xact_lock(hashtextextended(key, namespace)). The grelmicro-specific seed gives rate-limiter keys their own 64-bit lock-id space, isolating them from any other advisory lock in the same database and reducing intra-rate-limiter collisions from a 32-bit birthday risk to a 64-bit one. - ✅ Add a
tests/typing/sample (test_cache_generics.py) that usestyping.assert_typeto lock inTTLCache[T],PickleSerializer[T], andPydanticSerializer[T]inference end-to-end. A regression that widens inference back toAnyfailsuv run ty check. - ✅ Add a guard test that every
_LAZYkey ingrelmicro/resilience/__init__.pyis exported in__all__and actually resolves at runtime. - ✅ Add Hypothesis property tests for token-bucket and sliding-window math and for exponential backoff jitter bounds.
- ✅ Enable branch coverage (
--cov-branch). The 100% gate now covers both lines and branches. Defensive guards against impossible state are marked with# pragma: no branch. - 🔧 Document why every
type: ignoreandty: ignoreingrelmicro/_config.pyis required (Pydantic dynamic-subclass boundary). - 🔧 Explain the double-checked
pragma: no coverinReconfigurable.reconfigureso future contributors see the concurrent-caller intent. - 🔧 Add inline attribution cues to
grelmicro/task/_utils.pyandgrelmicro/resilience/_protocol.py/grelmicro/cache/_protocol.pyso readers immediately see where third-party adaptations live and that protocol bodies live in concrete adapters. - 🔧 Fix the
THIRD_PARTY_NOTICES.mdpath togrelmicro/resilience/ratelimiter/redis.py.
0.25.0 - 2026-05-21
Features
- ✨ Add
Timeoutreconfigurable resilience pattern.Timeout("db", seconds=2.0)wrapsasyncio.timeout, usable as an async context manager (async with db_timeout:) or decorator on async functions.TimeoutConfigis a frozen three-paths Pydantic config withseconds: PositiveFloat. Env prefixGREL_TIMEOUT_{NAME_UPPER}_. InheritsReconfigurable[TimeoutConfig]for live deadline swaps. Issue #176. - ✨ Add
Fallbackprimitive with decorator, block, and class forms.@fallback(when=..., default=...)/@fallback(when=..., factory=...)swap a matched exception for a safe value.async with falling_back(when=..., default=...) as result:covers inline blocks.Fallback("name", when=..., default=...)is the named, reconfigurable class form.FallbackConfigis a frozen three-paths Pydantic config withdefault/factorymutually exclusive.when=matches Retry's keyword so theMatchDSL stays universal. Composition order documented in Composing patterns. Issue #199. - ✨ Add
PostgresCacheAdapterfor Postgres-backed cache storage. Register viaGrelmicro(uses=[postgres, Cache(postgres)]). Entries land in a singlegrelmicro_cachetable keyed onkey TEXT PRIMARY KEYwithvalue BYTEAandexpires_at TIMESTAMPTZ.getfilters expired rows withWHERE expires_at > NOW(),setis oneINSERT ... ON CONFLICT DO UPDATE. Schema auto-migrates on first connect, opt out withauto_migrate=False. Optional janitor reclaims storage whencleanup_interval=is set (off by default). Issue #167. - ✨ Add
PostgresRateLimiterAdapterfor fleet-wide rate limiting on Postgres. Register viaGrelmicro(uses=[postgres, RateLimiters(postgres)])andRateLimiter.token_bucket(...)orRateLimiter.sliding_window(...)runs against a singlegrelmicro_rate_limitertable.acquireandpeekeach run one round-trip to a PL/pgSQL function. Concurrent writes for the same key are serialized withpg_advisory_xact_lock. Schema and functions auto-migrate on first connect, opt out withauto_migrate=False. Issue #164.
0.24.0 - 2026-05-18
Features
- ✨ Add
CircuitBreakerStrategyProtocol andCircuitBreakerBackend.bind(name, config) -> Strategy. Mirrors the RateLimiter shape so a second algorithm plugs in without breaking changes.CircuitBreakerConfiggains akind: Literal["consecutive_count"]discriminator. Issue #163. - ✨ Add
RedisCircuitBreakerAdapterfor fleet-wide breaker state. Register viaGrelmicro(uses=[redis, CircuitBreakers(redis)])andCircuitBreaker("name")consults Redis for admission, counters, and transitions. Half-open admission cap is enforced globally via atomic Lua scripts.last_errorandlast_error_timestay per-replica. Issue #163. - ✨ Add
CircuitBreaker.consecutive_count(name, ...)factory classmethod, mirroringRateLimiter.token_bucket(...)andRateLimiter.sliding_window(...). Each algorithm of every Pattern lands as a classmethod on the Pattern class. The algorithm-config module loads lazily on first call. Issue #163. - ✨
grelmicro.resilienceis now a PEP 562 lazy package:from grelmicro.resilience import CircuitBreakerno longer loadsRateLimiter, its algorithm configs, or memory/redis adapters. Same in the other direction. Top-level__getattr__dispatches to the right subpackage on first access. Issue #163.
Breaking
- 💥
CircuitBreaker.transition_to_closed,transition_to_open,transition_to_half_open,transition_to_forced_open,transition_to_forced_closed, andrestartare nowasync def. Addawaitat every call site. Issue #163. - 💥
CircuitBreakerBackendProtocol is now lifecycle +bind(name, config). Custom backends should return aCircuitBreakerStrategyinstance frombind.register(breaker)and the local fast-path are dropped: every backend (includingMemoryCircuitBreakerAdapter) goes through the Strategy. Memory state lives in adapter-owned dicts keyed by breaker name.CircuitBreakerSharedStaterenamed toCircuitBreakerSnapshot. Issue #163. - 💥
CircuitBreakerConfigis now aDiscriminator("kind")-tagged union (matchesRateLimiterConfig). InstantiateConsecutiveCountConfig(...)directly. The algorithm config lives atgrelmicro.resilience.circuitbreaker.consecutive_count. Issue #163. - 💥
FORCED_OPENandFORCED_CLOSEDno longer incrementconsecutive_error_count/consecutive_success_count. Per-replicatotal_error_count/total_success_countstill tick. Dashboards keying off consecutive counts during forced states need updating. Issue #163. - 💥 Resilience layout: each Pattern is now a subpackage with its algorithm configs and adapters as siblings.
grelmicro.resilience.memory→grelmicro.resilience.circuitbreaker.memoryandgrelmicro.resilience.ratelimiter.memory.grelmicro.resilience.redis→grelmicro.resilience.circuitbreaker.redisandgrelmicro.resilience.ratelimiter.redis.grelmicro.resilience.algorithmsis gone: rate-limiter configs live atgrelmicro.resilience.ratelimiter.{token_bucket,sliding_window}, circuit-breaker configs atgrelmicro.resilience.circuitbreaker.consecutive_count. Top-levelfrom grelmicro.resilience import ...shortcuts are unchanged. Issue #163. - 💥 Rename Components:
Breaker→CircuitBreakers,RateLimit→RateLimiters. Plural matches existing Component convention (Tasks,HealthChecks). Mechanical migration: replaceBreaker(...)withCircuitBreakers(...)andRateLimit(...)withRateLimiters(...)at every call site. Issue #163. - 💥
CircuitBreaker.__init__drops the algorithm kwargs path (error_threshold=,success_threshold=,reset_timeout=,half_open_capacity=,log_level=,ignore_exceptions=,env_prefix=,env_load=). Signature is nowCircuitBreaker(name, config=None, *, backend=None), matchingRateLimiter. UseCircuitBreaker.consecutive_count("name", error_threshold=5, ...)for the simple case,CircuitBreaker("name", ConsecutiveCountConfig(...))for the declarative case, or bareCircuitBreaker("name")for defaults. Env loading viaGREL_CIRCUIT_BREAKER_*is gone: build the config frompydantic-settingsif you need that. Issue #163. - ✨
CircuitBreakerandRateLimiterfall back to a process-global implicitMemoryCircuitBreakerAdapter/MemoryRateLimiterAdapterwhen noCircuitBreakers/RateLimitersComponent is registered.CircuitBreaker("payments")andRateLimiter.token_bucket("api", capacity=10, refill_rate=1)work without anyGrelmicro(uses=[...])setup. Fleet-wide opt-in stays explicit (Grelmicro(uses=[redis, CircuitBreakers(redis), RateLimiters(redis)])). Issue #163.
0.23.0 - 2026-05-17
Breaking
- 💥 Rename the discriminator field from
typetokindon every tagged union. AffectsRateLimiterConfig(TokenBucketConfig,SlidingWindowConfig) andRetryBackoffConfig(ExponentialBackoff,ConstantBackoff,LinearBackoff,FibonacciBackoff,RandomBackoff). Serialized YAML and JSON configs must replacetype:withkind:(for exampleGREL_RETRY_FOO_BACKOFF={"kind":"exponential",...}). Frees the Pythontypebuiltin from being shadowed on every config object. Issue #268. - 💥 Rename
GCRAConfigtoSlidingWindowConfigandRateLimiter.gcra(...)toRateLimiter.sliding_window(...). The discriminator value also moves from"gcra"to"sliding_window". Modulegrelmicro.resilience.algorithms.gcrabecomesgrelmicro.resilience.algorithms.sliding_window. Internal strategy classes (_RedisGCRA,_MemoryGCRA) keep their names since they describe the underlying algorithm. Issue #259.
Features
- ✨ Add
LogandTracecomponents. RegisterLog()andTrace()inGrelmicro(uses=[...])to wire observability through the same verb asSync,Cache,RateLimit,Breaker, andTasks.Log()wrapsgrelmicro.log.configure(...)and snapshots stdlib root handlers on enter so sequential apps in tests do not stack handlers.Trace()owns an OTelTracerProvider: builds it fromTracingConfig(env prefixGREL_TRACE_), installs it on enter, shuts it down and restores the prior global provider on exit. OTLP HTTP and gRPC exporters are lazy-imported. Issue #224.
Docs
- 📝 Add Testing page documenting
micro.override(...)and the conftest recipe. Issue #236. - 📝 Add Capability matrix page covering Pattern × Adapter pairs for
1.0.0. Issue #161.
0.22.0 - 2026-05-16
Features
- ✨ Add
Grelmicroapp object andComponentprotocol. The user composes everything attached to the app into one container and opens it withasync with micro:. SingleGrelmicro.use(item)registration verb (anduses=constructor kwarg) acceptsComponentinstances (registered with(kind, name)lookup, exposed onmicro.<kind>), first-party backends (auto-wrapped into their matching Component:RedisCacheAdapter→Cache,RedisSyncAdapter→Sync), and any other async context manager (lifecycled only, caller keeps the reference). Typed accessorsmicro.syncandmicro.cacheprovide IDE completion.Grelmicro.componentsreturns the registered Components in order for/healthz-style introspection. Issue #208, epic #201, unified in #219,Componentrename and.componentsaccessor in #233. - ✨ Add
Synccomponent. Wraps aSyncBackendand exposeslock(...),task_lock(...),leader_election(...)factories. Use it viaGrelmicro(uses=[redis, Sync(redis)])(Provider-direct) orSync(MemorySyncAdapter())(Backend-direct). Reach it onmicro.sync. Issue #210. - ✨ Add
Cachecomponent. Wraps aCacheBackendand exposes attl(...)factory that builds aTTLCachebound to the wrapped backend. Use it viaGrelmicro(uses=[redis, Cache(redis)])(Provider-direct) orCache(MemoryCacheAdapter())(Backend-direct). Reach it onmicro.cache. Issue #212. - ✨ Add Component-direct Provider API.
Sync,Cache,RateLimit, andBreakeraccept aProvideror aBackendinstance. When given a Provider, the Component callsprovider.sync(),provider.cache(),provider.ratelimiter(), orprovider.breaker()to build the matching adapter. AddProviderbase class ingrelmicro.providers._basewith the four factory methods. AddRedisProvider.ratelimiter()returning aRedisRateLimiterAdapter. The Adapter classes stay public as escape hatches for custom Providers, but the recommended user code usesSync(redis)instead ofSync(RedisSyncAdapter(provider=redis)). - ✨ Add
Grelmicro.current()classmethod for ambient lookup. Insideasync with micro:it returns the active app for the current asyncio task. - ✨ Add
Retryprimitive with decorator, block, and class forms. Five backoff algorithms ship:ExponentialBackoff(default, with full jitter),ConstantBackoff,LinearBackoff,FibonacciBackoff, andRandomBackoff.when=is required and accepts aMatch(or shorthand). Live reconfiguration viaReconfigurable[RetryConfig]. Three-paths configuration. Underlying exception is re-raised with a PEP 678 note on exhaustion. Issue #165. - ✨ Add
MatchandOutcometogrelmicro.resilience.Matchis the resilience-wide outcome filter DSL:Match.exception(...),Match.result(...),Match.exception_message(...),Match.exception_cause(...),Match.predicate(...),Match.always(),Match.never()plus theirnot_*twins, composed with the|and&operators.Outcome[T]is the dataclass passed to custom predicates (exception,result,raised). Issue #242. - ✨ Add
grelmicro.providers.redis.RedisProvider. First-class Redis connection holder shared across components:RedisProvider("redis://..."),RedisProvider(host="...", port=...),RedisProvider()(env-driven viaREDIS_*),RedisProvider.from_config(RedisConfig(...)), andRedisProvider.from_client(client, own=False)for bring-your-own clients.Grelmicrodedupes implicit providers by(provider_class, env_prefix), so two adapters with the same prefix share one connection pool. Issue #226. - ✨ Add
grelmicro.providers.postgres.PostgresProvider. First-class Postgres connection holder wrapping anasyncpg.Pool:PostgresProvider("postgresql://..."),PostgresProvider(host=..., database=..., user=..., password=...),PostgresProvider()(env-driven viaPOSTGRES_*),PostgresProvider.from_config(PostgresConfig(...)), andPostgresProvider.from_client(pool, own=False)for bring-your-own pools. Shares the same(provider_class, env_prefix)dedupe asRedisProvider. Issue #255.
Breaking
- 💥 Patterns
RateLimiter,CircuitBreaker, and the FastAPI health router resolve through the activeGrelmicroapp. Add the two new ComponentsRateLimit(wrapsRateLimiterBackend, kind"ratelimiter") andBreaker(wrapsCircuitBreakerBackend, kind"circuitbreaker").Grelmicro.use(...)auto-wraps aRateLimiterBackendorCircuitBreakerBackendinstance into its matching Component.HealthChecksbecomes a Component (kind = "health", defaultname = "default"). Pass it toGrelmicro(uses=[...])and the FastAPIhealth_router()resolves it viaGrelmicro.current(). Delete therate_limiter_backend_registry,circuit_breaker_backend_registry,health_checksregistries plusgrelmicro/_backends.py(BackendRegistry,BackendNotLoadedError,BackendAlreadyRegisteredError). Closes out #201. Issue #261. - 💥 Redis adapters now take
provider=orenv_prefix=, not a positionalurl=.RedisSyncAdapter,RedisCacheAdapter, andRedisRateLimiterAdapterlose theirurl=argument. Passprovider=RedisProvider(...)to share a pool, or rely onenv_prefix=(defaultREDIS_) to build one. Issue #226. - 💥
PostgresSyncAdapternow takesprovider=orenv_prefix=, not a positionalurl=. Passprovider=PostgresProvider(...)to share a pool, or rely onenv_prefix=(defaultPOSTGRES_) to build one. Issue #255. - 💥 Rename
TaskManagertoTasks. Class still extendsTaskRouter, mirroring FastAPI'sAPIRouter←FastAPIshape. Update imports tofrom grelmicro.task import Tasks. Issue #218. - 💥 Rename
HealthRegistrytoHealthChecks(andHealthRegistryConfigtoHealthChecksConfig). Update imports tofrom grelmicro.health import HealthChecks. Issue #201. - 💥 Rename concrete backends to
*Adapter.MemorySyncBackend,RedisSyncBackend,PostgresSyncBackend,SQLiteSyncBackend,KubernetesSyncBackend,MemoryCacheBackend,RedisCacheBackend,MemoryRateLimiterBackend,RedisRateLimiterBackend, andMemoryCircuitBreakerBackendbecome*Adapter. TheSyncBackend,CacheBackend,RateLimiterBackend, andCircuitBreakerBackendProtocols stay as-is. Issue #201. - 💥 Rename nested backoff classes to drop the redundant
Configsuffix.ExponentialBackoffConfig,ConstantBackoffConfig,LinearBackoffConfig,FibonacciBackoffConfig, andRandomBackoffConfigbecomeExponentialBackoff,ConstantBackoff,LinearBackoff,FibonacciBackoff, andRandomBackoff. TheRetryBackoffConfigdiscriminated-union alias and the JSON discriminator (type: "exponential") are unchanged. Issue #239. - 💥 Rename the
Moduleprotocol toComponentto avoid clashing with Python's own "module".ModuleAlreadyRegisteredErrorbecomesComponentAlreadyRegisteredErrorandModuleNotRegisteredErrorbecomesComponentNotRegisteredError. TheSyncandCacheclasses keep their names. No deprecation shim. Issue #233. - 💥 Rename the env-loading flag to align the per-call kwarg and the global env var.
read_env=becomesenv_load=on every component,GREL_CONFIG_FROM_ENVbecomesGREL_ENV_LOAD, and thegrelmicro._config.env_opt_in_enabled()helper becomesenv_load_default(). No deprecation shim. Issue #232. - 💥 Remove the module-level registry and lifespan API.
grelmicro.lifespan()is gone. Theregister/unregister/use/use_backend/use_registryhelpers acrossgrelmicro.{sync,cache,health,resilience}(plus the resilience circuit-breaker variants) are gone. Patterns (Lock,TaskLock,LeaderElection,TTLCache) now resolve their backend viaGrelmicro.current()at every call. Build aGrelmicro(uses=[...])and open it withasync with micro:. Thegrelmicro.sync._backendsandgrelmicro.cache._backendsmodules are removed (sync and cache resolve through the app). The internalrate_limiter_backend_registry,circuit_breaker_backend_registry, andhealth_checksregistries stay private until follow-up issues introduce their Component wrappers. Issue #207. - 💥
Retryoutcome filter is nowwhen=accepting aMatch. The oldon=parameter is gone. TheMatchDSL (Match.exception(...),Match.result(...),Match.exception_message(...),Match.exception_cause(...),Match.always(),Match.never(),Match.predicate(...)) plus theirnot_*twins and the|/&operators cover the common retry-filter surface. Bare-class shorthand is still accepted (when=httpx.HTTPError). Result-based retry lands in the same change:when=Match.result(None)retries until the function stops returningNone. Env var renamedGREL_RETRY_{NAME}_ON→GREL_RETRY_{NAME}_WHEN. Issue #242.
Internal
- ⚡ Defer the
opentelemetryimport ingrelmicro.trace.import grelmicro.traceno longer loadsopentelemetry(was 16 modules). The package is resolved lazily on first call toinstrument,span, oradd_contextand cached. Issue #189.
0.21.0 - 2026-05-06
Breaking
- 💥 Drop Python 3.11. The new floor is
requires-python = ">=3.12". RHEL 9 (App Streampython3.12) and RHEL 10 (default) ship 3.12 and the UBI images are available, so enterprise users are covered. Issue #66. - 💥 Drop AnyIO. grelmicro now targets
asynciodirectly. Issue #183. - 💥
CircuitBreakernow takes a backend (CircuitBreakerBackend). The in-memory backend (MemoryCircuitBreakerBackend) is the default. A future Redis-backed implementation will share state across replicas (issue #188). The async API stays primary, sync code goes throughcb.from_thread. - 💥 The sync adapters on
Lock,TaskLock,TTLCache, andCircuitBreakernow require the backend to be opened (async with backend:orgrelmicro.lifespan()). The backend captures the running loop and the sync adapter dispatches through it. Zero hot-path overhead. - 💥 Resilience registries are now namespaced. The rate limiter registry name moves from
"resilience"to"resilience.ratelimiter"and the circuit breaker registry is"resilience.circuitbreaker".grelmicro.lifespan(exclude=...)now matches by dotted prefix, soexclude={"resilience"}still skips both registries.
Features
- ✨ Add
uvloopto thestandardextra (Linux and macOS). Activate withuvloop.run(main()).
Internal
- ✅ Migrate the test suite from
pytest.mark.anyiotopytest-asynciowithasyncio_mode = "auto". AnyIO is no longer a direct dependency of grelmicro (it may still arrive transitively, for example throughfast-depends). - ♻️ Adopt PEP 695 generic syntax (
class Foo[T]:,def f[T](...),type X = ...) across_backends.py,_config.py,_types.py,health/_types.py,trace/_instrument.py, andtests/task/conftest.py. Two files keep the older form: the recursive aliases in_json.py(ty cannot expand recursive PEP 695 aliases) and the decorator factory incache/cached.py(PEP 695 binds the inner decorator to the outer scope's type parameters, breaking per-decoration-site inference). Issue #65. - 🔨 Bump
tool.ruff.target-versiontopy312and the CI matrix to["3.12","3.13","3.14"].
0.20.0 - 2026-05-03
Live reconfiguration is complete. Every stateful primitive now exposes reconfigure(new_config), so you can hot-reload from a ConfigMap or SIGHUP without restarting the process. See Live reconfiguration for the contract.
Features
- ✨ Add
RateLimiter.reconfigure(new_config). Swap algorithm config without rebuilding the limiter. PR #153. - ✨ Add
reconfigure(new_config)toLock,TaskLock, andLeaderElection. Swap timing fields without restarting. Theworkerfield cannot change. PR #159. - ✨ Add
CircuitBreaker.reconfigure(new_config). Swap thresholds andignore_exceptionswithout restarting. Runtime state andlast_errorare kept.log_levelis applied to the logger. PR #160. - ✨ Add
HealthRegistry.reconfigure(new_config). Swapcache_ttland the defaulttimeoutwithout restarting. Per-check timeouts stay as registered. PR #180.
Docs
- 📝 Reframe README and docs landing as a microservice patterns toolkit. PR #155.
- 📝 Replace "Production-ready" with "Railguarded": 100% pytest coverage, ty-checked, ruff-linted, Pydantic-validated. PR #181.
Internal
- 🔨 Switch build backend to Hatch. PR #155.
- 🎨 Supersample favicon PNGs with Lanczos downscaling for smoother anti-aliasing. PR #156.
0.19.0 - 2026-05-01
Cleans out the long-deprecated APIs (ResilienceException, Synchronization, scheduled(), the token= kwarg) ahead of the 1.0.0 design work, ships a 3.4× speedup on env-driven config construction, and brings the test suite under 20s for contributors.
Breaking
- 💥 The Environmental config path is now opt-in. Set
GREL_CONFIG_FROM_ENV=trueonce at startup to enable env reads across every component, or passread_env=Trueper call. The per-call value (True/False) always wins over the global flag. This stops grelmicro from silently picking up ambient env vars in unit tests or scripts. Issue #142. - 💥 The
read_envkwarg default flips fromTruetoNoneon every component.Nonefollows the global flag.TrueandFalsekeep their meaning as explicit per-call overrides. - 💥 Remove obsolete deprecation shims that were marked for removal in 0.7.0. Replace
ResilienceExceptionwithResilienceError,SynchronizationwithSyncPrimitive, and thescheduled()decorator onTaskRouter/TaskManagerwithinterval(seconds=N, max_lock_seconds=N*5). Thetoken=kwarg onLockAcquireError,LockReleaseError, andLockNotOwnedErroris removed (drop it from your code). Thesync=parameter oninterval()no longer warns when used with non-Lockprimitives.
Internal
- ♻️ Add
grelmicro._config.env_opt_in_enabled()helper that exposes the truthyGREL_CONFIG_FROM_ENVcheck (1,true,yes,on, case-insensitive). Issue #142. - 📝 Document the "no field-mirroring" decision in
docs/architecture/config.mdwith the benchmark numbers frombenchmarks/config_attr_benchmark.py. Closes Issue #113 without code changes: hot-path config reads cost <1% of a real call (~2 ns out of ~250 ns), so we keepself._configas the single source of truth instead of copying frozen fields onto the component. - 🔇 Silence the upstream
testcontainers@wait_container_is_readydeprecation banner via a scopedfilterwarningsentry inpyproject.toml. Replace the unawaitedlambda: sleep(math.inf)mock side-effect intests/sync/test_leaderelection.py::test_leadership_abandon_on_renew_deadline_reachedwith an explicit async helper. The full suite now reports zero warnings. - ⚡ Speed up the test suite from ~73s to ~19s by adding
pytest-xdist(-n autoinaddopts) and shrinking expiration sleeps intests/sync/test_backends.py. Fix--durationsreporting by removing the autousefreeze_timefixture:@freeze_time()decorator stays on the two tests that comparedatetime.now(), the two tests that previously calledfrozen_time.tick(...)switch tomonkeypatch.setattr(circuitbreaker, "monotonic", ...). Issue #125. - ⚡ Cache the dynamic
BaseSettingssubclass built bygrelmicro._config._build_settings_clswith@functools.lru_cache(maxsize=256). The env path ofresolve_confignow reuses the same_<Config>Settingssubclass across calls instead of rebuilding it every time, which makesLock("cart")-style construction ~3.4× faster (232 µs/op → 68 µs/op). The bound is a safety net for long-running processes that might derive prefixes from runtime inputs. Issue #119. - ♻️ Rename the local
parent_configtomerged_configin_build_settings_clsand document why the existing# type: ignorecomments are needed. Issue #127.
0.18.0 - 2026-04-30
M2 milestone closed: backend wiring is now fully explicit. Construction is pure (no global writes), registration is named (<module>.register(backend, "name")), and grelmicro.lifespan(*ad_hoc, exclude=...) walks every registry that has been imported and opens its registered backends in one call. Task-scoped overrides via with <module>.use(...): swap backends per request or per test through contextvars.
Breaking
- 💥 Backend constructors are now pure:
__init__performs no registry writes. Theauto_registerkwarg is removed from every backend and fromHealthRegistry. PR #138. - 💥
BackendRegistry.setis renamedregisterandBackendRegistry.unregisteris added with an identity check.resetremains for test fixtures. PR #138. - 💥
async with backendopens the connection but no longer registers. Callregister(backend)(oruse_backend(backend)) to register, or open everything at once withgrelmicro.lifespan(). PR #139. - 💥
BackendRegistryis now multi-name:register(backend, name="default"),unregister(name, backend=None),get(name="default"). PR #139. - 💥 The sync registry name changed from
"lock"to"sync"(used in error messages andlifespan()exclude keys). The rate limiter registry changed from"rate_limiter"to"resilience". PR #139. - 💥 Overwriting a registered name with a different instance now raises
BackendAlreadyRegisteredError(was: warning + replace). Re-registering the same instance stays a no-op. PR #139.
Features
- ✨ Add
grelmicro.sync.use_backend,grelmicro.cache.use_backend,grelmicro.resilience.use_backend, andgrelmicro.health.use_registryfor explicit, idempotent process-lifetime registration. PR #138. - ✨
grelmicro.lifespan(*ad_hoc, exclude=...)opens every registered backend across every imported module in one call, with reverse-order shutdown. PR #139. - ✨ Per-module helpers
register,unregister,use_backend,useongrelmicro.sync,grelmicro.cache,grelmicro.resilience(anduse_registry,useongrelmicro.health). PR #139. - ✨ Task-scoped overrides via
<module>.use(backend)or<module>.use(default=a, analytics=b). Stacks LIFO viacontextvarsfor per-test and per-request substitution. PR #139. - ✨ Primitives accept
backend=as either a backend instance or a registered name (Lock("audit", backend="analytics")). The registry is consulted on each call so<module>.use(...)overrides apply. PR #139. - ✨ Registries subscribe themselves on import:
lifespan()walks only modules that are actually imported, so unused components have zero RAM cost and zero startup work. PR #139. - ✨ Lookup falls back to the sole registered entry when no
"default"is named, so the single-backend case stays one-call. PR #139.
0.17.0 - 2026-04-29
Breaking
- 💥
CircuitBreakerconfig moves to a frozenCircuitBreakerConfig. Read it viacb.config. PR #132. - 💥 The mutable attributes
cb.error_threshold,cb.success_threshold,cb.reset_timeout,cb.half_open_capacity,cb.ignore_exceptions,cb.log_levelare removed. Construct a newCircuitBreakerto change config. PR #132. - 💥 Rename
grelmicro.loggingtogrelmicro.logandgrelmicro.tracingtogrelmicro.trace. Avoids shadowing stdlibloggingand aligns with the OpenTelemetry /ddtracetrace(singular) convention. Update imports:from grelmicro import log, trace. PR #135. - 💥
configure_logging()is renamedlog.configure(). Uselog.configure_with(config)for the declarative path. Both return the appliedLoggingConfig. PR #135. - 💥
LoggingSettings(theBaseSettingsshadow class) is removed.LoggingConfigis the config class. Env reading happens insidelog.configure(). PR #135. - 💥
LoggingConfigfield names move to lowercase:LOG_BACKEND→backend,LOG_LEVEL→level,LOG_FORMAT→format,LOG_TIMEZONE→timezone,LOG_JSON_SERIALIZER→json_serializer,LOG_CALLER_ENABLED→caller_enabled,LOG_OTEL_ENABLED→otel_enabled. PR #135. - 💥 Env vars move from
LOG_*toGREL_LOG_*to align with the rest of the library. PR #135. - 💥
LoggingSettingsValidationErroris removed.pydantic.ValidationErrorpropagates fromlog.configure()like every other component. PR #135.
Features
- ✨ Add
CircuitBreakerConfigandCircuitBreaker.from_config(name, config). PR #132. - ✨
CircuitBreakerreadsGREL_CIRCUIT_BREAKER_<NAME>_*env vars and acceptsenv_prefix=/read_env=. PR #132. - ✨
ignore_exceptionsaccepts fully-qualified import strings ("builtins.ValueError") so YAML and env loaders can specify exception types. PR #132. - ✨ Env vars for tuple/list fields accept comma-separated values in addition to JSON arrays. PR #132.
- ✨
log.configure(**kwargs)accepts everyLoggingConfigfield as a kwarg, mirroring the three-paths contract used by other components. PR #135. - ✨
log.configure_with(config)is the declarative entry point. Returns the appliedLoggingConfig. PR #135.
Internal
- ♻️ Add
grelmicro/_types.pyfor shared lightweight type aliases (LogLevel). PR #132. - ♻️ Add
grelmicro/_config.py::parse_csv_or_jsonshared utility for env var list parsing. PR #132.
Docs
- 🎨 Switch logo typeface from Funnel Sans to Funnel Display.
0.16.1 - 2026-04-29
Internal
- ✅ "No registry call at construction" tests now patch the registry source instead of the per-module import alias, so a future refactor that bypasses the local alias can no longer silently pass the check. PR #130.
- ⬆️ Bump
tyfrom 0.0.29 to 0.0.30. PR #111. - ⬆️ Pre-commit autoupdate. PR #114.
0.16.0 - 2026-04-29
Breaking
- 💥
LockConfig,TaskLockConfig,LeaderElectionConfig, andRateLimiterConfigno longer carry anamefield. Pass the name positionally:Lock("cart", LockConfig(lease_duration=30)). PR #123. - 💥 Rename
TokenBuckettoTokenBucketConfigandGCRAtoGCRAConfig.RateLimiterConfigbecomes the discriminated union of algorithm configs. PR #123. - 💥
RateLimitertakes the algorithm config positionally:RateLimiter("api", GCRAConfig(limit=100, window=60)). Thealgorithm=,limit=,window=kwargs are removed. PR #123. - 💥
fail_openmoves fromRateLimiter(...)to the algorithm config. PR #123.
Features
- ✨ Add
Component.from_config(name, config)to every primitive (Lock,TaskLock,LeaderElection,RateLimiter,HealthRegistry,RateLimitFilter,DuplicateFilter). PR #123. - ✨ Read environment variables under
GREL_<COMPONENT>_<NAME>_*for every component that supports the environmental path. PR #123. - ✨ Add
RateLimiter.token_bucket(name, ...)andRateLimiter.gcra(name, ...)factory classmethods. PR #123. - ✨ Add
env_prefix=andread_env=kwargs to every component that exposes the environmental path. PR #123. - ✨ Normalise instance names like
payments-eu,cart.v2, orweather/svcinto POSIX env var segments. PR #123.
Changed
- ♻️
Lock,TaskLock,LeaderElection, andRateLimiternow resolve the backend lazily on first use instead of at construction.BackendNotLoadedErrorsurfaces on the firstacquire/peek/resetcall rather than in__init__. Each component exposes a publicbackendproperty. PR #128.
Fixed
- 🐛 Auto-registered backends now identity-check before clearing the registry on
__aexit__, so a replacement instance is left alone. PR #122. - 🐛
Lock.releaseclears local ownership only after the backend confirms the release. PR #122.
0.15.0 - 2026-04-29
Breaking
- 💥 Redesign the
healthmodule:@health.check("name")decorator, binaryok/errorstatus, empty probe bodies, per-check caching. PR #112. - 💥 Endpoint renames:
/health/live→/livez,/health/ready→/readyz. New/healthzreturns the full check JSON. PR #112. - 💥
HealthRegistry.check()renamed torun(). Thecheckname is now the decorator. PR #112. - 💥
HealthCheckerProtocol removed. Use plaindeforasync deffunctions. PR #112. - 💥
HealthReport.components: listbecomesHealthReport.checks: dict[name, ...]. PR #112. - 💥
HealthCheckTimeoutErrorand the three-stateHealthStatusremoved. PR #112.
Docs
- 📝 Restate the versioning policy: pre-1.0
MINORmay break,PATCHnever. Post-1.0 deprecations get twoMINORreleases.
0.14.3 - 2026-04-22
Docs
- 🐛 Fix the wordmark duplicating on PyPI and other renderers that don't understand GitHub's theme-only URL fragments. PR #109.
0.14.2 - 2026-04-22
Docs
- 🐛 Fix the landing-page wordmark disappearing when the docs site is toggled into dark mode. PR #108.
- 📝 Centre the badges row under the tagline. PR #108.
0.14.1 - 2026-04-22
Docs
- 🎨 Ship the grelmicro brand identity: wordmark, favicon, and social-preview card. PR #106.
- 🎨 Refresh the docs theme with the brand palette. PR #106.
- 📝 Rewrite the "Why grelmicro" pillars. PR #106.
- 📝 Split the resilience docs into per-pattern pages.
- 📝 Add an Installation guide with
pip,uv, andpoetrytabs. - 📝 Render PEP 727
Annotated[..., Doc(...)]parameter docs viagriffe-typingdoc. - 📝 Plain-English pass on docs and docstrings for non-native readers.
- 📝 Add a Mermaid state diagram to the Circuit Breaker page.
- 📝 Document every
__all__symbol in the API reference. - 📝 Add a plain-English style guide to
CONTRIBUTING.md.
Internal
- 🐛 De-flake
test_lock_reentrant_from_threadon Python 3.12. Fixes #105. - 🔧 Add keywords to
pyproject.tomlfor PyPI discovery.
0.14.0 - 2026-04-21
Features
- ✨ Add pluggable
RateLimiteralgorithms via thealgorithm=parameter:TokenBucketandGCRA. PR #102. - ✨ Add
MemoryTokenBucket, a standalone synchronous token-bucket primitive. PR #102. - ✨ Add
RateLimitFilter, alogging.Filterwith configurablekey_mode. PR #102. - ✨ Add
DuplicateFilter, alogging.Filterthat caps repeated records per key with optional TTL. PR #94. - ✨
HealthRegistrynow logs every unhealthy path atWARNING(ERRORfor unexpected exceptions). PR #92.
Deprecations
- 🗑️
RateLimiter(name, limit=..., window=...)is deprecated. UseRateLimiter(name, algorithm=GCRA(limit=..., window=...))instead. Will be removed in 0.15.0. PR #102.
Docs
- 📝 Add
CONTRIBUTING.mdwith repo conventions. PR #102. - 📝 Add a "Choosing an algorithm" guide for
TokenBucketvsGCRAin the Rate Limiter docs. PR #102. - 📝 Surface
THIRD_PARTY_NOTICES.mdin the docs site. PR #102.
Security
- 🔒️ Harden CI supply chain: pin all Actions to SHAs, close
run:injection vectors, add zizmor workflow-lint, restrict Dependabot auto-merge to uv patch/minor updates. PRs #95, #100, #101.
Internal
- ⬆️ Bump
pydanticto 2.13.0,opentelemetry-api/opentelemetry-sdkto 1.41.0,pytestto 9.0.3,ruffto 0.15.10,tyto 0.0.29,fastapito 0.135.3,uvicornto 0.44.0. PR #99. - ⬆️ Bump
pydantic-extra-typesfrom 2.11.1 to 2.11.2. PR #89. - ⬆️ Pre-commit
ruffautoupdate (v0.15.9 → v0.15.11). PR #91. - ⬆️ Bump
codecov/codecov-actionto v6. PR #96. - ⬆️ Bump
astral-sh/setup-uvto v8. PR #97. - ⬆️ Bump
dependabot/fetch-metadatato v3. PR #98.
0.13.0 - 2026-04-08
Features
- ✨ Add
RateLimiter.peek(key): check rate limit state without consuming tokens. PR #90. - ✨ Add
RateLimiter.reset(key): delete rate limit state for a key, restoring full quota. PR #90. - ✨ Add
fail_openparameter toRateLimiter: return allowed result on backend errors instead of propagating exceptions. PR #90.
0.12.0 - 2026-04-07
Features
- ✨ Add
healthmodule with health check registry, concurrent checker execution, and FastAPI integration for liveness/readiness probes. PR #84.
Internal
- ⬆️ Bump orjson from 3.11.7 to 3.11.8. PR #72.
- ⬆️ Bump ty from 0.0.26 to 0.0.27. PR #74.
- ⬆️ Update uv-build requirement from
<0.10.0to<0.12.0. PR #75. - 👷 Add build provenance attestations and wheel verification to release pipeline.
- ♻️ Pre-release cleanup: add health/json to overview, fix style inconsistencies, remove stale branches.
0.11.0 - 2026-04-03
Breaking Changes
- 💥 Logging: split
callerinto separatelogger(logger name) andcaller(function:line) fields.calleris now opt-in viaGREL_LOG_CALLER_ENABLED(default:False), following common structured-logging conventions. Uvicorn formatter never includescaller. - 💥 Cache: replace
TTLCacheserializer/deserializercallable pair with a singleserializeraccepting aCacheSerializerprotocol object. UseJsonSerializer(),PydanticSerializer(Model), orPickleSerializer()instead.
Features
- ✨ Add
GREL_LOG_CALLER_ENABLEDsetting to opt in to caller info (function:line) in log records. Disabled by default for cleaner logs and better performance. - ✨ Add
loggerfield (logger name, e.g.,myapp.api) to all log records across all backends and formats. - ✨ Add
grelmicro.jsonmodule with fast JSON serialization usingorjsonwhen available, with automatic fallback to stdlibjson.
0.10.0 - 2026-04-02
Features
- ✨ Add
RateLimiterto theresiliencemodule: Redis-backed sliding-window rate limiting using the GCRA algorithm. IncludesRateLimitResultwith fields mapping to IETF rate limit headers, weighted requests viacostparameter, andRateLimitExceededError.
Removals
- 🗑️ Remove deprecated
UvicornJSONFormatterandUvicornAccessJSONFormatter. UseUvicornFormatterandUvicornAccessFormatterinstead (deprecated since 0.9.1).
CI
- ⚡ Migrate PyPI publishing from API token to OIDC trusted publishing.
0.9.1 - 2026-04-01
Deprecations
- 🗑️
UvicornJSONFormatterandUvicornAccessJSONFormatterare deprecated. UseUvicornFormatterandUvicornAccessFormatterinstead. The new formatters respectGREL_LOG_FORMATinstead of always producing JSON. Old names kept as aliases withDeprecationWarning.
0.9.0 - 2026-04-01
Breaking Changes
- 💥
GREL_LOG_FORMATdefault changed fromJSONtoAUTO. In production (non-TTY), behavior is identical (JSON output). In local dev (TTY), output switches to human-readableTEXTwith colors. SetGREL_LOG_FORMAT=JSONexplicitly to restore the previous default.
Features
- ✨ Add
AUTOlog format (new default): detects TTY and selectsTEXT(terminal) orJSON(piped/CI). - ✨ Add
LOGFMTlog format: key-value pairs following the logfmt convention, 30-40% smaller than JSON. - ✨ Add
PRETTYlog format: multi-line indented output with structured error rendering. - ✨ Enhanced
TEXTformat: now includes extra context fields askey=valuepairs and supports ANSI colors. - ✨ Add
NO_COLOR/FORCE_COLORenvironment variable support following no-color.org standard.
0.8.0 - 2026-04-01
Breaking Changes
- 💥 Backend imports moved to submodules. Use
from grelmicro.sync.redis import RedisSyncBackendinstead offrom grelmicro.sync import RedisSyncBackend. Same for all sync, cache, and logging backends. See Import Strategy.
Features
- ✨ Add Uvicorn JSON formatters (
UvicornJSONFormatter,UvicornAccessJSONFormatter) for structured logging viadictConfig.
0.7.0 - 2026-03-31
Breaking Changes
- 💥 Logging JSON format redesigned to follow industry standards:
loggerrenamed tocallerthreadremovedctxremoved: extra fields are now flat at the top levelexceptionreplaced by structurederrorobject (type,message,stack)
Features
- ✨ Add
tracingmodule with@instrumentdecorator,span()context manager, andadd_context()for unified logging and OTel instrumentation.
Performance
- ⚡ Logging: Up to +23% throughput across all backends.
- ⚡ Use
OrderedDictfor O(1) LRU operations inTTLCache.
Refactors
- ♻️ Extract shared Redis config into
grelmicro/_redis.py. - ♻️ Make
TTLCachegeneric and addDocannotations. - ♻️ Extract context stack into
grelmicro/_context.pyto decouple logging from tracing. - ♻️ Filter private (
_-prefixed) attributes from stdlib JSON log output. - ♻️ Widen
@instrument(skip=...)type fromset[str]toAbstractSet[str].
Removals
- 🗑️
Synchronizationprotocol removed. UseSyncPrimitiveinstead (deprecated since 0.6.0). - 🗑️
ResilienceExceptionremoved. UseResilienceErrorinstead (deprecated since 0.6.0). - 🗑️ The
tokenparameter on lock errors removed (deprecated since 0.6.0). - 🗑️ The
syncparameter oninterval()removed (deprecated since 0.6.0). - 🗑️ The
scheduled()decorator removed (deprecated since 0.6.0).
0.6.0 - 2026-03-30
Deprecations
- 🗑️
Synchronizationprotocol renamed toSyncPrimitive. The old name still works but emits aDeprecationWarning. Will be removed in 0.7.0. - 🗑️
ResilienceExceptionrenamed toResilienceError. The old name still works but emits aDeprecationWarning. Will be removed in 0.7.0. - 🗑️ The
tokenparameter onLockAcquireError,LockReleaseError, andLockNotOwnedErroris deprecated. Tokens are no longer included in error messages for security. Will be removed in 0.7.0. - 🗑️ The
syncparameter oninterval()forTaskLockandLeaderElectionis deprecated. Usemax_lock_secondsandleaderparameters instead. Will be removed in 0.7.0. - 🗑️ The
scheduled()decorator is deprecated. Useinterval()withmax_lock_secondsorleaderinstead. Will be removed in 0.7.0.
Features
- ✨ Add in-memory TTL cache with LRU eviction, per-key stampede protection, and
@cacheddecorator. - ✨ Add
RedisCacheBackendfor distributed cache storage. - ✨ Add cache statistics via
CacheInfo(hits, misses, evictions, stampedes). - ✨ Add Kubernetes sync backend using Lease resources (
pip install grelmicro[kubernetes]). - ✨ Add SQLite sync backend for home lab and local testing (
pip install grelmicro[sqlite]).
Security
- 🔒️ Remove token values from lock error messages to prevent leaking in logs.
- 🔒️ Upgrade
requeststo 2.33.0 (CVE fix in transitive dependency).
Refactors
- ♻️ Unify error hierarchy under
GrelmicroErrorbase class. All module errors (SyncError,ResilienceError,LoggingError,TaskError,CacheError) now share a common base. - ♻️ Use server-side timestamps and native Lease fields in sync backends.
- ♻️ Simplify token generation from UUID-based to string concatenation.
- ♻️ Harden TaskLock token nonce and error handling.
Internal
- ✅ Achieve 100% library code coverage.
- 💚 Fix flaky integration test timeout in CI.
- ⬆️ Bump dependencies and fix ty v0.0.26 type errors.
Docs
- 📝 Add cache module documentation with usage guide and API reference.
- 📝 Add Kubernetes Backend Architecture page.
- 📝 Add SQLite Backend Architecture page.
- 📝 Add backend comparison matrix to Coordination guide.
- 📝 Rewrite README with project vision.
0.5.0 - 2026-03-17
Breaking Changes
- 💥 Add namespace prefix to sync primitive backend keys (
lock:,tasklock:,leader:). See Migration Guide below.
Features
- ✨ Add
TaskLock.from_threadthread-safe adapter. PR #57. - ✨ Add specific lock error classes (
LockAcquireError,LockReleaseError,LockLockedCheckError,LockOwnedCheckError,LockReentrantError). PR #57.
Refactors
- ♻️ Consolidate distributed lock and leader gating into the
interval()decorator viamax_lock_secondsandleaderparameters. PR #54.
Docs
- 📝 Add Coordination Architecture page. PR #57.
Internal
- ⬆️ Bump redis, fastapi, pydantic, and pydantic-settings. PR #55.
- ⬆️ Update pre-commit hooks. PR #50.
Migration Guide
Namespace-Prefixed Backend Keys
Prior versions used the name parameter directly as the backend key. Now each primitive adds a type-specific prefix:
| Primitive | Name | Backend Key |
|---|---|---|
Lock("my-resource") |
my-resource |
lock:my-resource |
TaskLock("cleanup") |
cleanup |
tasklock:cleanup |
LeaderElection("main") |
main |
leader:main |
Existing locks stored in Redis or PostgreSQL will no longer match after upgrading. A running instance on the old version and one on the new version will not see each other's locks.
Upgrade all running instances together so they use the same key format. Old keys expire automatically via their lease duration (Redis PEXPIRE / PostgreSQL expire_at).
0.4.1 - 2026-03-13
Docs
- 📝 Add Task Lock to synchronization primitives guide.
Internal
- ⬆️ Bump actions/checkout to v6 and astral-sh/setup-uv to v7.
0.4.0 - 2026-03-13
Features
- ✨ Add
TaskLockfor distributed task locking with auto-renewal. - ✨ Add
GREL_LOG_TIMEZONEsupport for configurable timezone in logging output. - ✨ Add OpenTelemetry trace context injection into log records.
- ✨ Add
structlogas alternative logging backend. - ✨ Add configurable JSON serializer (
json/orjson) for logging.
Docs
- 📝 Add logging benchmark and performance documentation.
Internal
0.3.2 - 2026-01-27
Internal
- 👷 Migrate from mypy to Astral ty for type checking. PR #45.
- 🔧 Add Python 3.14 support. PR #47.
- 🔧 Switch build system to
uv_build. PR #49. - 💚 Simplify CI and release workflow. PR #24.
0.3.1 - 2025-06-05
Docs
- 📝 Add resilience patterns section and update links in README and index.
Internal
- 💚 Fix release pipeline and GitHub Pages deployment permissions.
0.3.0 - 2025-06-05
Features
- ✨ Add Circuit Breaker resilience pattern. PR #18.
Docs
- 📝 Refactor code examples to use snippets.
Internal
0.2.3 - 2024-12-04
Features
- ✨ Add Redis key prefix support to avoid conflicts in shared instances.
- ✨ Add Redis and PostgreSQL settings management from environment variables.
0.2.2 - 2024-11-28
Features
- ✨ Add PostgreSQL backend configuration from environment variables.
Internal
0.2.1 - 2024-11-26
Internal
- 💚 Set up release workflow with version tagging.
0.2.0 - 2024-11-26
First public release.
Features
- ✨ Add distributed
Lockwith lease-based expiration. - ✨ Add
LeaderElectionfor single-leader task execution. - ✨ Add
IntervalTaskscheduler for periodic tasks with synchronization support. - ✨ Add Redis, PostgreSQL, and in-memory synchronization backends.
- ✨ Add logging module with JSON and TEXT formatting via
GREL_LOG_LEVELandGREL_LOG_FORMATenvironment variables.
Docs
- 📝 Add MkDocs documentation site with Material theme.
Internal
- 👷 Add unified CI workflow with linting, testing, and coverage.