Skip to content

Migration

One section per minor release from 0.30, newest first. Each section opens with two lists:

  • Changes that do not raise: your code keeps running, but something behaves differently. Nothing warns you, so read this list first.
  • Changes that raise: an import, a constructor, the startup or the first call fails, and the error names what to change.

The detailed entries follow, with a Before and an After where code changes. Each section ends with a link to its full changelog. Seeing an error or an odd metric already? Match it in the table below.

For a version before 0.30, read the Breaking sections in the changelog, newest first.

In 0.x the minor is the breaking position: a 0.x.0 release may change the public API, and a 0.x.y release is a safe patch. Versions and upgrades says what each release may change and how long a changed default warns first. So an upgrade within one minor rarely needs this page. A few patches are exceptions, and each one sits in its minor's section: 0.32.2 closes an open circuit once, 0.35.1 reformats uvicorn's lines and warns on an ignored variable, 0.37.1 refuses a provider URL that a client library used to accept, and 0.42.1 renames Starlette mount routes and moves when an ambiguous provider raises.

Find your symptom

Symptom Release Fix
AttributeError: 'Operation' object has no attribute 'response' 0.30 Use result()
A password or URL reads back as SecretStr(...) or *** 0.31, 0.32 Call .get_secret_value()
Metrics stopped arriving after upgrading, with no error 0.31 Set an endpoint
TypeError on a SQLite adapter, either unexpected keyword argument 'path' or takes 1 positional argument 0.32 Pass provider=
An open circuit closed once, just after upgrading 0.32.2 Nothing to change
TypeError: @cached on ... needs an explicit key= 0.34 Name the key
Task run totals jumped after upgrading, with no new failures 0.34 Filter on the outcome
A /healthz consumer fails on a missing error field 0.35 Read an absent error as a pass
A log pipeline stopped parsing uvicorn's lines 0.35.1 Update the parser or turn it off
GrelmicroConfigWarning fails a test run under -W error 0.35.1 Fix the configuration
ImportError: cannot import name 'LogTimeZoneType' 0.36 Use TimeZoneName
SettingsValidationError: unknown timezone name on a value like CEST 0.36 Name the zone
ImportError: cannot import name 'RateLimiterRegistry' or ImportError: cannot import name 'CircuitBreakerRegistry' 0.37 Rename to Component
TypeError: health_router() got an unexpected keyword argument 'registry' 0.37 Pass component=
A provider error on a URL that used to connect 0.37.1 Fix the URL
A bare GREL_{PATTERN}_ variable changed every instance, not only the default one 0.38 Name the default instance
ModuleNotFoundError: No module named 'grelmicro.clientip' 0.39 Import from grelmicro.security
A logging filter or level set on grelmicro.clientip stopped matching 0.39 Rename the logger
ImportError: cannot import name 'LogSettingsValidationError', or any other *SettingsValidationError 0.40 Catch the base error
SettingsValidationError where you caught pydantic.ValidationError from Fallback, Shield, or TTLCache 0.40 Catch the base error
SettingsValidationError where you caught ValueError or TypeError from cached(), TrustedProxies, or ExternalConfig 0.40 Catch the base error
SettingsValidationError on a bad Lock name, adapter table_name, or Redis prefix 0.40 Catch the base error
SettingsValidationError: environment= must be one of ... on a Grelmicro(...) that used to build 0.40 Name a real tier
ValueError where you caught TypeError from any Match argument error or a bad when= 0.40 Catch ValueError
ImportError: cannot import name 'RedisProviderConfigError' or 'PostgresProviderConfigError' 0.40 Catch the base error
A dashboard or alert on a grelmicro metric label, such as outcome or task_name, shows no data 0.41 Update the queries
A dashboard on grelmicro.bulkhead.rejections or grelmicro.rate_limiter.decisions stopped receiving data 0.41 Update the queries
grelmicro.retry.attempts jumped after upgrading, with no new failures 0.41 Update the queries
/livez, /readyz, /healthz or /metrics is missing from the OpenAPI schema 0.41 Pass include_in_schema=True
A JSON log field reads differently, such as a UUID, an Enum or non-ASCII text 0.41 Pin the serializer
An idempotent request with Authorization or Cookie runs again instead of replaying 0.41 Pass a key function
A request retried across the upgrade ran twice 0.41 Nothing to change
Uvicorn's startup and error lines left stderr 0.41 Turn the pass off
A @retry or @fallback on a callable object never engaged, or @measure recorded almost no time for it 0.41 Nothing to change
TypeError: ... only decorates async functions from @timeout, @bulkhead or @shield on a callable object 0.41 Nothing to change
TypeError: @cached(ttl=...) supports async functions only, or AttributeError: '...' object has no attribute '__qualname__' from @cached 0.41 Nothing to change
EventLoopDeadlockError from a CircuitBreaker on a callable object, which an except Exception did not catch 0.41 Nothing to change
TypeError: ... got an unexpected keyword argument 'lock' or 'leader' from every 0.42 Pass gate=
A cron task runs on every replica after upgrading 0.42 Pass gate="claim"
Rate limits and lockouts reset after upgrading 0.42 Keep the overlap short
A caller gets twice its rate limit during a rolling deploy 0.42 Keep the overlap short
Two leaders, or a task running twice, during a rolling deploy 0.42 Stop every 0.41 worker first
SettingsValidationError: lock= takes 'process', 'host', 'cluster' or None 0.42 Name the fold scope
OutOfContextError from a @cached function or get_or_set with lock="cluster" 0.42 Register a lock backend
A get_or_set value computed once per replica after upgrading 0.42 Pass lock="cluster"
TypeError: ... got an unexpected keyword argument 'wait_timeout' 0.42 Pass max_wait=
ValidationError from BulkheadConfig(max_wait=None), or SettingsValidationError on GREL_BULKHEAD_{NAME}_MAX_WAIT set to none 0.42 Pass 0
BackendScopeError: ... is bound to InProcessLock on Idempotency or IdempotentRequests 0.42 Register a lock backend
CachedResponses misses run once per replica after upgrading 0.42 Pass lock="cluster"
ImportError: cannot import name 'metrics_router' from 'grelmicro.metrics' 0.42 Import from grelmicro.integrations.fastapi
ModuleNotFoundError: No module named 'grelmicro.metrics.fastapi' 0.42 Import from grelmicro.integrations.fastapi
ImportError: cannot import name 'CacheError' from 'grelmicro.cache' 0.42 Delete the except CacheError: block
SettingsValidationError: Could not validate settings: min_hold_duration must be greater than or equal to interval 0.42 Hold the claim for the interval
SettingsValidationError or ValueError: ... must be whole seconds or a timedelta 0.42 Pass whole seconds or a timedelta
TypeError: TaskRouter.every() got an unexpected keyword argument 'seconds' 0.42 Pass interval=
TypeError: ... got an unexpected keyword argument 'misfire_grace_seconds' 0.42 Pass misfire_grace=
TypeError: ... got an unexpected keyword argument 'ignore_exceptions' from a CircuitBreaker 0.42 Pass when=
TypeError: ... got an unexpected keyword argument 'timeout_errors' from a Shield 0.42 Pass when=
SettingsValidationError: ... when: Field required from a Shield 0.42 Pass when=
TypeError: '_ShieldDecorator' object is not callable from a bare @shield 0.42 Pass a preset and when=
AttributeError: 'TaskLock' object has no attribute 'refresh' 0.42 Follow the extend() rename
LockExtendError where you caught LockAcquireError or LockReleaseError from extending a lease 0.42 Catch LockExtendError
LockNotOwnedError passes an except LockReleaseError or except LockBackendError 0.42 Catch LockNotOwnedError
A dashboard or alert on grelmicro.lock.renewals stopped receiving data 0.42 Follow the extend() rename
TypeError: key must be a function, use key_template= for a template from @cached or @limiter 0.42 Pass key_template=
TypeError: ... got an unexpected keyword argument 'key_maker' 0.42 Pass key=
ImportError: cannot import name 'IdempotencyKeyMakerError' 0.42 Catch IdempotencyKeyFunctionError
SettingsValidationError naming GREL_SHIELD_{NAME}_PROFILE or GREL_SHIELD_PROFILE: is no longer read 0.42 Choose the preset in code
SettingsValidationError: TaskLock has no name 0.42 Update the lock name
SettingsValidationError: Invalid lock name ... from a TaskLock, a LeaderElection or a gated task 0.42 Update the lock name
SettingsValidationError: Invalid task name ... The prefix 'task-' is reserved 0.42 Update the lock name
A dashboard on grelmicro.lock.name lost a task defined in a script run directly 0.42 Update the lock name
AttributeError: 'RedisProvider' object has no attribute 'lock', or 'cache', 'leaderelection' and the other pattern names 0.42 Call lock_backend()
AttributeError: 'Coordination' object has no attribute 'rwlock_backend' or 'election_backend' 0.42 Read readwritelock_backend
TypeError: Coordination.__init__() got an unexpected keyword argument 'rwlock' or 'election' 0.42 Pass readwritelock= or leaderelection=
TypeError: Rename MyProvider.lock() to lock_backend() 0.42 Rename it lock_backend()
ImportError: cannot import name 'document_idempotency', or 'document_conditional_requests' and 'document_rate_limited_requests' 0.42 Register the component
A URL no route answers is no longer served from the cache or replayed, on an app without AuthenticatedRequests 0.42 Declare the route
404 or 405 where a write without If-Match got 428, on an app without AuthenticatedRequests 0.42 Nothing to change
404 or 405 where a request without Idempotency-Key got 400 from require_key=True, on an app without AuthenticatedRequests 0.42 Nothing to change
A path an include= pattern names under middleware on a mount or a mounted app is no longer cached 0.42 Declare the route
A route declaring CachedResponse() is no longer cached by a CachedResponsesMiddleware added by hand 0.42 Register the component
A middleware added by hand answers before a route's own checks, such as an authentication dependency 0.42 Register the component
TypeError: ... got an unexpected keyword argument 'policies' from CachedResponsesMiddleware 0.42 Drop it
GREL_CIRCUITBREAKER_{NAME}_IGNORE_EXCEPTIONS or GREL_SHIELD_{NAME}_TIMEOUT_ERRORS no longer applies, with no error 0.42.0, 0.42.1 Rename the variable to _WHEN
SettingsValidationError: ... IGNORE_EXCEPTIONS: is no longer read or ... TIMEOUT_ERRORS: is no longer read 0.42.2 Rename the variable to _WHEN
GREL_IDEMPOTENT_REQUESTS_WAIT_TIMEOUT no longer applies, with no error 0.42.0, 0.42.1 Rename the variable to _MAX_WAIT
SettingsValidationError: ... WAIT_TIMEOUT: is no longer read 0.42.2 Rename the variable to _MAX_WAIT
A dashboard on http.server.duration stopped receiving data on FastAPI 0.42 Read http.server.request.duration, in seconds
ImportError: cannot import name 'MetricsExporterType', or another mode enum such as 'LogLevelType' 0.44 Pass the string
AttributeError: 'str' object has no attribute 'value' on a mode field such as config.exporter, or '_UppercaseFormat' object has no attribute 'value' on LogConfig.format 0.44 Compare with the string
TypeError: HealthChecks.__init__() got an unexpected keyword argument 'auto_health' 0.44 Pass provider_checks=
/healthz lists provider: checks after upgrading 0.44 Pick a mode
BackendScopeWarning: Fold(...) is bound to InProcessLock or rides Coordination('default') on startup 0.44 Register a Coordination
A GREL_* variable applies after upgrading, where it used to warn and be ignored 0.44 Set GREL_ENV_LOAD=0
SettingsValidationError when a component is built, from a GREL_* variable with an invalid value such as GREL_OPS_PORT=abc 0.44 Fix the variable or set GREL_ENV_LOAD=0

0.44

Changes that do not raise

  • A mode field such as MetricsConfig.exporter or LogConfig.level reads back as a plain str, not as its old enum member. ==, str() and the JSON dump are unchanged. Compare with the string.
  • A HealthChecks with no provider_checks= beside a Provider shows each Provider in /healthz as a non-critical provider:{short_name} check. /readyz is unchanged. Pick a mode.
  • A HealthChecks with no provider_checks=, entered with async with inside an open app rather than listed in uses=, now registers a check for each Provider on that app. Pick a mode.
  • Under micro.fake(), a HealthChecks with no provider_checks= now shows one provider:memory check for the fake's store, whichever Provider the app lists. Pick a mode.
  • LogConfig.format still reads back in uppercase, such as "JSON". Comparing it with a format name now warns with Grelmicro045Warning. From 0.45.0 it reads back in lowercase, such as "json". Compare in any case.
  • The built-in log formats are spelled in lowercase. Type checkers still accept LogFormat values in uppercase, such as "JSON", until 0.45.0. Compare in any case.
  • HealthChecks(provider_checks=None) now means "report" (0.43: none) and warns with Grelmicro045Warning. It is removed in 0.45.0. Omit the argument for "report", or pass "off" to register none.
  • @cached(lock="host") and lock="cluster" are checked when the app opens. A fold with no Coordination, or one whose lock backend falls short, now warns with BackendScopeWarning on startup, in staging and production too. A miss behaves as in 0.43. Register a Coordination.
  • A GREL_* variable such as GREL_OPS_PORT, set while GREL_ENV_LOAD is unset, is now read. 0.43 ignored it and warned with EnvLoadDefaultWarning. Set GREL_ENV_LOAD=0 to keep it unread.
  • EnvLoadDefaultWarning is no longer emitted. Reading it warns with Grelmicro045Warning, and it is removed in 0.45.0. Filter on EnvLoadOffWarning.
  • A name that grelmicro.integrations.fastapi, grelmicro.integrations.starlette or grelmicro.integrations.litestar only imported in 0.43, such as RateLimiter or ErrorResponses, still imports and now warns with Grelmicro045Warning. It leaves the module in 0.45.0. Import it from the module the warning names, such as grelmicro.resilience. AdmissionError from grelmicro.security.bans and GrelmicroError from grelmicro.security.jwt work the same way. Import them from grelmicro.errors.
  • gate-backend stays a warning in staging and production. It says the check fails there from 0.45.0 and warns with Grelmicro045Warning. Give each gated task a backend.

Changes that raise

  • MetricsExporterType, TraceExporterType, TraceProcessorType, TraceSamplerType, LogLevelType, LogFormatType, LogBackendType and LogSerializerType are removed. Pass the string.
  • .value and .name on a mode field raise AttributeError. Compare with the string.
  • HealthChecks(auto_health=) and HealthChecks.from_config(auto_health=) raise TypeError. Pass provider_checks=.
  • GREL_ENVIRONMENT=Production, or any tier in another case, declares that tier and no longer warns. In staging and production an unmet backend scope then raises BackendScopeError at startup. Check the 0.43 warning.
  • A GREL_* variable with an invalid value, such as GREL_OPS_PORT=abc, raises SettingsValidationError when the component is built, since the variable is now read. Fix the variable or set GREL_ENV_LOAD=0.

A mode field is a plain string

The mode enums are gone. Pass the string and compare with the string. A choice still matches in any case.

from grelmicro.metrics import Metrics
from grelmicro.metrics.config import MetricsConfig

# Before
from grelmicro.metrics import MetricsExporterType

Metrics(exporter=MetricsExporterType.PROMETHEUS)
MetricsConfig().exporter.value == "auto"

# After
Metrics(exporter="prometheus")
MetricsConfig().exporter == "auto"

To annotate a value of your own, use the type that replaces each enum, such as MetricsExporter.

provider_checks defaults to "report"

HealthChecks() registers a non-critical check for each Provider on the app. A down backend shows in /healthz and leaves /readyz at 200. The auto_health= argument is gone.

from grelmicro.health import HealthChecks

# Before
HealthChecks(auto_health=True)
HealthChecks(auto_health=False)
HealthChecks()  # no provider checks
HealthChecks(provider_checks=None)  # no provider checks

# After
HealthChecks(provider_checks="require")
HealthChecks(provider_checks="off")
HealthChecks(provider_checks="off")  # keeps no provider checks
HealthChecks(provider_checks="off")  # keeps no provider checks

provider_checks=None now means "report" and warns with Grelmicro045Warning. It is removed in 0.45.0. Omit the argument for "report", or pass provider_checks="off" to register none.

A log format is spelled in lowercase

The built-in formats are spelled auto, json, logfmt, text and pretty. Input still matches in any case, so format="JSON" and GREL_LOG_FORMAT=JSON keep working.

LogConfig.format reads back as "JSON" in 0.44 and as "json" from 0.45.0. A comparison with a format name, such as config.format == "JSON", warns. Compare in any case, and the code works in both releases:

from grelmicro.log import configure

config = configure(format="json")

# Before: warns, and turns false in 0.45.0
if config.format == "JSON":
    ...

# After
if config.format.lower() == "json":
    ...

A custom loguru template reads back as written and never warns.

config.format is a str subclass in 0.44, so isinstance(config.format, str) holds but type(config.format) is str does not. From 0.45.0 it is a plain str.

LogFormat lists both spellings until 0.45.0, so a value annotated LogFormat such as "JSON" still passes the type checker. An exhaustive match on a LogFormat that ends in assert_never now needs the lowercase cases too:

from typing import assert_never

from grelmicro.log import LogFormat


def describe(value: LogFormat) -> str:
    match value:
        case "JSON" | "json":
            return "one JSON object per line"
        case "LOGFMT" | "logfmt":
            return "key=value pairs"
        case "TEXT" | "text" | "PRETTY" | "pretty" | "AUTO" | "auto":
            return "for humans"
        case _:
            assert_never(value)

@cached(lock=...) is checked on startup

@cached(lock="host") and lock="cluster" fold misses through the lock backend of the app's Coordination. The backend check now reads that lock backend when the app opens, not on the first miss. micro.describe() and grelmicro check report it as backend-scope.

A fold with no Coordination, or one whose lock backend reaches less far than lock, warns with BackendScopeWarning on startup. That holds with no tier declared and in staging and production. A miss behaves as in 0.43. With no Coordination it raises OutOfContextError. With a lock backend that falls short, it raises BackendScopeError in staging and production, and warns and computes the value with no tier declared.

Any app opened in the process reports a decorated fold, and lock="process" or a Coordination that reaches far enough silences it. Register a Coordination that reaches far enough:

# Before: warns on startup, raises OutOfContextError on a miss
micro = Grelmicro(uses=[Cache(redis)])

# After
micro = Grelmicro(uses=[Cache(redis), Coordination(redis)])

Or pass lock="process" to fold misses in each process only.

GREL_* variables are read by default

Every GREL_* field is read unless GREL_ENV_LOAD=0. A variable set while GREL_ENV_LOAD is unset now applies, with no warning. An app that already set GREL_ENV_LOAD=1 or GREL_ENV_LOAD=0 behaves as in 0.43.

# 0.43: warns, GREL_OPS_PORT is not read
# 0.44: GREL_OPS_PORT is read
GREL_OPS_PORT=9100

# Keep ignoring every GREL_* field, in 0.43 and 0.44
GREL_ENV_LOAD=0

A variable with an invalid value, such as GREL_OPS_PORT=abc, now raises SettingsValidationError when the component is built. Fix the value, or unset the variable.

env_load=False on one component keeps its fields unread. A keyword argument still beats the variable. Provider variables such as POSTGRES_URL are read as before.

GREL_ENV_LOAD=0 still reports each variable it leaves unread, with EnvLoadOffWarning. Filter on that category, not on EnvLoadDefaultWarning:

# Before
filterwarnings = ["ignore::grelmicro.EnvLoadDefaultWarning"]

# After
filterwarnings = ["ignore::grelmicro.EnvLoadOffWarning"]

Full list: 0.44.0 in the changelog

0.43

Changes that do not raise

  • A mode field such as MetricsConfig.exporter or LogConfig.level still reads back as its enum member, which equals the string. From 0.44.0 it reads back as a plain str, so .value, .name and is against a member stop working. .value and .name now warn with GrelmicroFutureWarning. Compare with the string.
  • MetricsExporterType, TraceExporterType, TraceProcessorType, TraceSamplerType, LogLevelType, LogFormatType, LogBackendType and LogSerializerType warn with GrelmicroFutureWarning and are removed in 0.44.0. Type checkers report them as deprecated. Pass the string.
  • GREL_ENVIRONMENT=Production, or any tier in another case, still reads as undeclared and now also warns with GrelmicroFutureWarning. From 0.44.0 it declares that tier. Spell the tier in lowercase.
  • A GREL_* variable such as GREL_OPS_PORT, set while GREL_ENV_LOAD is unset, is still not read and now warns with EnvLoadDefaultWarning, a GrelmicroFutureWarning that is also an EnvLoadOffWarning, so a filter on EnvLoadOffWarning still silences it. From 0.44.0 it is read. Set GREL_ENV_LOAD.
  • HealthChecks(auto_health=True) and auto_health=False still work and now warn with GrelmicroFutureWarning, and type checkers report them as deprecated. auto_health= is removed in 0.44.0. Pass provider_checks=.
  • A HealthChecks with no provider_checks= beside a Provider still registers no provider checks, and now warns with GrelmicroFutureWarning on startup. From 0.44.0 it shows each Provider in /healthz. Pass provider_checks=.
  • A choice in another case is accepted where it used to be refused, such as requires="Cluster", lock="Process", gate="Claim" or environment="Production". Nothing to change.

A mode is a string

An argument that picks a mode takes a string, matched in any case. The enum each one took before still works in 0.43 and warns.

from grelmicro.log import configure
from grelmicro.metrics import Metrics

# Before
from grelmicro.log import LogBackendType, LogFormatType
from grelmicro.metrics import MetricsExporterType

Metrics(exporter=MetricsExporterType.PROMETHEUS)
configure(backend=LogBackendType.LOGURU, format=LogFormatType.JSON)

# After
Metrics(exporter="prometheus")
configure(backend="loguru", format="JSON")

Until 0.44.0 a field reads back as the old enum member, which equals the string. Compare with the string, config.exporter == "prometheus", and the code keeps working when the field becomes a plain str. To annotate a value of your own, use the type that replaces each enum:

Before After
grelmicro.metrics.MetricsExporterType grelmicro.metrics.MetricsExporter
grelmicro.trace.TraceExporterType grelmicro.trace.TraceExporter
grelmicro.trace.TraceProcessorType grelmicro.trace.TraceProcessor
grelmicro.trace.TraceSamplerType grelmicro.trace.TraceSampler
grelmicro.log.LogLevelType grelmicro.types.LogLevel
grelmicro.log.LogFormatType grelmicro.log.LogFormat
grelmicro.log.LogBackendType grelmicro.log.LogBackend
grelmicro.log.LogSerializerType grelmicro.log.LogSerializer

GREL_LOG_LEVEL=info still reads as INFO. Every other choice now matches in any case too, such as requires=, lock=, gate= and environment=. GREL_ENVIRONMENT is the exception until 0.44.0, see below.

A tier in another case warns

GREL_ENVIRONMENT=Production reads as undeclared, as before, and now also warns with GrelmicroFutureWarning. From 0.44.0 it declares production. In staging and production an unmet backend scope then raises BackendScopeError at startup, so check the warning before upgrading. The environment= argument already matches in any case: it used to raise on "Production", so accepting it changes no app that started.

# Before: warns, reads as undeclared
GREL_ENVIRONMENT=Production

# After: declares the tier now, and the same in 0.44.0
GREL_ENVIRONMENT=production

GREL_* variables are read by default from 0.44.0

A GREL_* variable set while GREL_ENV_LOAD is unset is not read, as before, and now warns with EnvLoadDefaultWarning once per variable. From 0.44.0 every GREL_* field is read by default, so that variable then applies. Set GREL_ENV_LOAD to choose now, and the warning stops:

# Before: warns, GREL_OPS_PORT is not read
GREL_OPS_PORT=9100

# Read it now, the same as 0.44.0
GREL_ENV_LOAD=1
GREL_OPS_PORT=9100

# Or keep ignoring every GREL_* field, in 0.43 and 0.44.0
GREL_ENV_LOAD=0

env_load=True or env_load=False on one component chooses for that component only. Provider variables such as POSTGRES_URL are read as before.

auto_health is renamed to provider_checks

HealthChecks(provider_checks=) picks the check registered for each Provider on the app. "report" shows it in /healthz and leaves /readyz alone, "require" fails /readyz while the backend is down, and "off" registers none. auto_health= still works in 0.43, warns, and is removed in 0.44.0.

from grelmicro.health import HealthChecks

# Before
HealthChecks(auto_health=True)
HealthChecks(auto_health=False)

# After
HealthChecks(provider_checks="require")
HealthChecks(provider_checks="off")

Left unset, provider_checks= registers nothing, as before, and warns on startup when the app has a Provider it would check. From 0.44.0 it defaults to "report". Pass provider_checks="off" to keep the current behavior, or provider_checks="report" to show each Provider in /healthz now.

Full list: 0.43.0 in the changelog

0.42

Changes that do not raise

  • A cron task with no gate= runs each fire on every worker. Pass gate="claim".
  • A gated task with no backend reports a coordination error and runs nothing. Register a Coordination.
  • Sliding window limits and lockouts start empty, and a caller gets up to twice its limit while 0.41 and 0.42 workers overlap. Keep the overlap short.
  • Redis leader election and a task with a derived lock name do not see a 0.41 worker. Stop every 0.41 worker first.
  • keep_delivered=1 keeps outbox rows for one second, not for good. Write None.
  • TTLCache.get_or_set folds concurrent misses in one process, not across replicas. Pass lock="cluster".
  • CachedResponses folds concurrent misses in one process, not across replicas. Pass lock="cluster".
  • The response cache, idempotency and conditional requests answer at the route. A URL no route answers is neither cached nor replayed. Declare the route.
  • A cache, idempotency or conditional middleware added by hand reads no route. Register the component.
  • An integration without the route hooks declares no route. Ship both hooks.
  • The grelmicro.lock.renewals metric is now grelmicro.lock.extensions. Follow the extend() rename.
  • A task named after a function whose name is not a valid lock name locks under a task- name, which is also its metric label. Update the lock name.
  • The default idempotency key follows the checks each route declares. More routes stop replaying across callers, and a FastAPI route with only grelmicro's own dependencies now replays. Pass a key function.
  • A Litestar handler with a guard, a dependency, a hook or middleware is never cached. Nothing to change.
  • On FastAPI, http.server.duration in milliseconds becomes http.server.request.duration in seconds, and the body size metrics are gone. FastAPI records the request spans and metrics itself. Read http.server.request.duration, in seconds.
  • On FastAPI, OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_* and OTEL_SEMCONV_STABILITY_OPT_IN no longer apply to requests. Remove the variables.
  • An access log added by hand names no route. A request answered before routing, such as a cached response, records a span with no route. Register it with micro.install(app).
  • A token with no sub, a typ other than JWT, JOSE or at+jwt, or a cnf claim is rejected. A malformed token no longer counts toward a ban. Issue tokens with sub and a plain typ.
  • grelmicro check and micro.describe(app) list the routes the integration declares, so some rows change name, split per method or disappear. Nothing to change.
  • In 0.42.0 and 0.42.1, GREL_CIRCUITBREAKER_{NAME}_IGNORE_EXCEPTIONS and GREL_SHIELD_{NAME}_TIMEOUT_ERRORS are ignored without an error. From 0.42.2 they raise. Rename the variable to _WHEN.
  • In 0.42.0 and 0.42.1, GREL_IDEMPOTENT_REQUESTS_WAIT_TIMEOUT and GREL_IDEMPOTENT_REQUESTS_{NAME}_WAIT_TIMEOUT are ignored without an error. From 0.42.2 they raise. Rename the variable to _MAX_WAIT.
  • A HEAD is answered from the stored GET only on a route that answers both, and a HEAD route of its own runs its handler. Declare the route.
  • With Trace or Metrics registered, FastAPI no longer exports anything itself, and its log records are not exported. Export through Trace and Metrics.
  • On FastAPI, a request span follows the semantic conventions 1.44 instead of 1.11. It has fastapi.dependencies, fastapi.endpoint and fastapi.serialization children instead of send and receive, and a WebSocket span is named like WS /ws/{room}. Update trace queries.
  • 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 Host route and a prefix a proxy stripped. Update log filters.
  • On a plain Starlette app, http.route carries the route template in the access log and security events. Update log filters.
  • grelmicro check, micro.describe(app) and CachedResponses match an include= URL one path segment per parameter, whatever its converter, so /items/abc names /items/{item_id:int}. Check the include= patterns.
  • ClientBannedError is an AdmissionError carrying retry_after, and ErrorResponses answers it 429 with a Retry-After header. Catch it as an AdmissionError.
  • From 0.42.1, on Starlette, a route under a Mount names every mount, such as /mm/r/a instead of /mm/a. Update route filters.

Changes that raise

  • A float duration is refused, and every(seconds=...) and misfire_grace_seconds= are renamed. Pass whole seconds or a timedelta.
  • every and cron refuse lock= and leader=, and a TaskLock gate that holds for less than the interval is refused. Pass gate=.
  • ignore_exceptions=, timeout_errors= and the bare @shield are gone, and a Shield requires when=. Pass when=.
  • A Shield refuses to build while GREL_SHIELD_{NAME}_PROFILE or GREL_SHIELD_PROFILE is set. Choose the preset in code.
  • From 0.42.2, with env reads on, GREL_CIRCUITBREAKER_{NAME}_IGNORE_EXCEPTIONS and GREL_SHIELD_{NAME}_TIMEOUT_ERRORS raise SettingsValidationError, at construction and on a reload. Rename the variable to _WHEN.
  • From 0.42.2, with env reads on, GREL_IDEMPOTENT_REQUESTS_WAIT_TIMEOUT and GREL_IDEMPOTENT_REQUESTS_{NAME}_WAIT_TIMEOUT raise SettingsValidationError, at construction and on a reload. Rename the variable to _MAX_WAIT.
  • metrics_router no longer imports from grelmicro.metrics. Import from grelmicro.integrations.fastapi.
  • The document_* functions are removed. Register the component.
  • CachedResponsesMiddleware(policies=...) is removed. Drop it.
  • CacheError is removed. Delete the except CacheError: block.
  • lock= refuses "local", True and False, and "host" or "cluster" with no lock backend raises OutOfContextError. Name the fold scope.
  • wait_timeout= is renamed max_wait=, and BulkheadConfig(max_wait=None) is refused. Pass max_wait=.
  • Idempotency and IdempotentRequests with no lock backend raise BackendScopeError in staging and production. Register a lock backend.
  • TaskLock.refresh() is renamed extend(), and a failed extension raises LockExtendError. Follow the extend() rename.
  • A string passed to key= raises TypeError, and key_maker= is gone. Pass key_template= or key=.
  • A TaskLock with no name raises when entered, and an invalid lock or task name is refused. Update the lock name.
  • Provider methods end in _backend, and a custom Provider with the old names fails at class definition. Call lock_backend().
  • grelmicro needs Python 3.14, FastAPI 0.142.1, Starlette 1.7 and higher dependency floors. Upgrade Python and the dependencies.
  • The JWT API changes: JWTKey.pem(), .secret() and .jwk() build a key, JWTVerifier.keys() and .jwks() build a verifier, audience is required, and JWKSVerifier, bans= and client= are gone. Use the new factories.
  • A pattern holding a backend of its own goes through the backend check, and IdempotentRequests refuses a memory Cache in production. Register a shared backend or pass requires="process".
  • CachedResponse() on a route that runs a check of its own fails install. Drop CachedResponse() from that route.
  • From 0.42.1, Grelmicro(uses=[p1, p2]) raises AmbiguousProviderError when the app opens, not at construction, and micro.use(p1) then micro.use(p2) raises it too. A component of a kind a provider default holds raises ComponentAlreadyRegisteredError once the defaults are registered. Name the component for each kind.

A stored duration is whole seconds or a timedelta

A lease, a TTL, a task schedule and a circuit breaker reset timeout take whole seconds as an int, or a timedelta. A float is refused, 60.0 included. Each config reads the value back as a timedelta.

from datetime import timedelta

from grelmicro.coordination import Lock

# Before
tasks.every(seconds=0.5)
tasks.cron("0 * * * *", misfire_grace_seconds=600)
Lock("cart", lease_duration=0.5)

# After
tasks.every(interval=timedelta(milliseconds=500))
tasks.cron("0 * * * *", gate="claim", misfire_grace=600)
Lock("cart", lease_duration=timedelta(milliseconds=500))

From an environment variable, a duration reads whole seconds ("60") or an ISO 8601 duration ("PT0.5S"). A decimal such as "0.5" is refused. A method or decorator argument, such as cached(ttl=...), refuses text.

What moved:

  • Task: every(seconds=...) is now every(interval=...), and cron misfire_grace_seconds is now misfire_grace. The old names are gone.
  • Coordination: lease_duration on Lock, ReadWriteLock, LeaderElection and TaskLock, renew_deadline on LeaderElection, and min_hold_duration on TaskLock.
  • Cache: TTLCacheConfig.ttl, Cache.ttl, cached(ttl, stale_ttl), and the ttl and stale_ttl of TTLCache.set, .get_or_set and .set_many.
  • HTTP: IdempotencyConfig.ttl, CachedResponsesConfig.ttl and its per-path include TTLs, and CachedResponse(ttl).
  • Rate limiter: the sliding window window.
  • Circuit breaker: reset_timeout on CircuitBreaker.consecutive_count and ConsecutiveCountConfig.
  • Security: ClientBans window and duration, and JWTVerifier ttl and cache_ttl (0 still turns the cache off).
  • Outbox: lease_duration, keep_delivered and purge(older_than=...). keep_delivered takes no bool. Write 0 for False (delete on delivery) and None for True (keep for good). keep_delivered=1 now means one second, where it used to become True and keep rows for good. From an environment variable, write 0 or none. Bool spellings such as "true" are refused, and "1" means one second too. purge(older_than=0) purges every delivered and dead row.
  • Log and health: DuplicateFilter ttl, and HealthChecks cache_ttl (0 still turns the cache off).

A backend of your own takes each lease, TTL or cool-down as a timedelta: the LockBackend, ReadWriteLockBackend, LeaderElectionBackend and CacheBackend protocols, CircuitBreakerStrategy.transition(cool_down), and LeaderRecord.lease_duration. An OutboxBackend takes claim(lease=...) as a timedelta, and purge takes older_than as a timedelta instead of before_seconds.

Redis leader election stores its record under new le_us: keys, so a leader on the previous version is not seen. Upgrade every worker at once, as State and rolling deploys explains.

State and rolling deploys

Four kinds of stored state move to new keys in 0.42. During a rolling or blue/green deploy, old and new workers share the backend, but each version reads and writes only its own keys.

State 0.41 0.42 While both versions run
Sliding window rate limit, on Redis, Postgres and SQLite keys under gcra:, Postgres <table>_gcra_* functions keys under gcra_us:, Postgres <table>_gcra_us_* functions Each version counts on its own, so a caller gets up to twice the limit. A caller locked out on 0.41 starts with a full budget on 0.42.
Redis leader election record under <name> record under le_us:<name> Each version elects its own leader.
An interval task whose function name is not a valid lock name lock named __main__:job lock named task-__main__:job, see lock names The task runs once per version.
A cached response on a route that declares its protection key without the route's protection key under the route's protection Each such response is cached again, and each version serves its own copies.

A token bucket keeps its keys.

For leader election and an interval task with a derived lock name, stop every 0.41 worker before the first 0.42 worker starts.

For a sliding window, the doubled budget lasts as long as the overlap. grelmicro ships no migration for the old counts, because they count a different unit. If a sliding window guards a security budget, such as failed sign-ins, expect every lockout to clear at the upgrade and keep the overlap short. The old Redis keys expire on their own within one window. The old Postgres and SQLite rows stay, as do the old Postgres functions. Once no 0.41 worker runs, you can delete them. With the default table and an empty prefix:

DELETE FROM grelmicro_rate_limiter WHERE substr(key, 1, 5) = 'gcra:';

With a prefix, put it before gcra: and count the whole string, such as substr(key, 1, 9) = 'app:gcra:'. Avoid LIKE, where a _ in the prefix matches any character.

One gate= for which workers run a task

every and cron take gate= instead of lock= and leader=, and both run on every worker by default. A cron task used to claim each fire whenever a Coordination component was wired. Pass gate="claim" to keep that:

# Before
@tasks.every(seconds=3600, lock=TaskLock(lease_duration=7200))
@tasks.every(seconds=60, leader=election)
@tasks.cron("0 3 * * *")

# After
@tasks.every(interval=3600, gate="claim")
@tasks.every(interval=60, gate=election)
@tasks.cron("0 3 * * *", gate="claim")

gate="claim" holds the claim for the whole interval, which the old lock=TaskLock(...) did not: its one-second hold let replicas with offset timers each run their own tick. A TaskLock you still pass as the gate needs a min_hold_duration of at least interval.

A gated task with no backend reports a coordination error on every fire instead of running on every worker. Register a Coordination component, or drop the gate when every worker should run it.

In 0.42.0, a cron task with no gate= runs each fire on every worker and nothing reports it. Pass gate="claim" to keep one worker per fire. From 0.42.1, such a task warns ungated-cron when it starts beside a schedule backend. Pass gate=None to keep every worker on purpose.

Every resilience pattern takes when=

CircuitBreaker and Shield name the errors they react to with when=, like Retry and Fallback. It takes an exception class, a tuple of classes, a predicate or a Match.

A breaker's when= names the errors that count as failures, and every Exception counts by default. An error you ignored becomes an exclusion:

# Before
CircuitBreaker.consecutive_count("payments", ignore_exceptions=ValidationError)

# After
CircuitBreaker.consecutive_count(
    "payments", when=Match.not_exception(ValidationError)
)

A Shield's when= names the errors that count as transient, and it is required. TimeoutError still always counts:

# Before
@shield.api(timeout_errors=(httpx.TimeoutException,))
async def fetch(url: str) -> bytes: ...


# After
@shield.api(when=httpx.TimeoutException)
async def fetch(url: str) -> bytes: ...

The bare @shield is gone, because it had no way to take when=. Name a preset and the errors instead:

# Before
@shield
async def ping() -> None: ...


# After
@shield.api(when=TimeoutError)
async def ping() -> None: ...

The same rename applies to ConsecutiveCountConfig, the Shield profile configs and the environment: GREL_CIRCUITBREAKER_{NAME}_IGNORE_EXCEPTIONS and GREL_SHIELD_{NAME}_TIMEOUT_ERRORS become GREL_CIRCUITBREAKER_{NAME}_WHEN and GREL_SHIELD_{NAME}_WHEN. An ignore list in the environment cannot be written as an exclusion, so list the errors that count as failures instead. Name each by its full path, such as builtins.ValueError or httpx.HTTPError.

The environment no longer picks a Shield preset

GREL_SHIELD_{NAME}_PROFILE and GREL_SHIELD_PROFILE are no longer read. With environment reads on, a Shield refuses to build while either is set, so a preset never changes in silence. The environment tunes a Shield's values, and code chooses its preset. A bare Shield(...) builds api. Remove the variable and name the preset it used to pick:

# Before: GREL_SHIELD_DB_PROFILE=internal
db = Shield("db", when=TimeoutError)

# After
db = Shield.internal("db", when=TimeoutError)

The decorators follow the same rule: write @shield.internal(...) or @shield.slow(...). GREL_SHIELD_{NAME}_WHEN and GREL_SHIELD_{NAME}_MAX_RATE still tune the preset code chose. A PROFILE key in a mounted ConfigMap or Secret is refused the same way: the Shield keeps its running config and a warning names the key.

metrics_router moved to grelmicro.integrations.fastapi

metrics_router builds a FastAPI router, so it now sits next to health_router:

# Before
from grelmicro.metrics import metrics_router

# After
from grelmicro.integrations.fastapi import metrics_router

metrics_asgi stays in grelmicro.metrics, since it needs no framework.

The document_* functions are removed

document_idempotency, document_conditional_requests and document_rate_limited_requests are gone from grelmicro.integrations.fastapi. A registered component needs nothing: micro.install(app) describes it in the OpenAPI schema on FastAPI and Litestar.

A middleware you added by hand and documented with one of them is registered as its component instead, with the same options:

# Before
app.add_middleware(IdempotencyMiddleware, idempotency=Idempotency("http"))
document_idempotency(app)

# After
micro = Grelmicro(uses=[cache, IdempotentRequests()])
micro.install(app)

A third-party integration declares its routes

This is for authors of an integration published through the grelmicro.integrations entry point. The response cache, idempotency and conditional requests decide per route, once the framework routed the request, from the declarations the integration hands them. grelmicro check and micro.describe(app) list the routes those declarations name. An integration without the hooks declares no route, and grelmicro check warns routes-not-declared.

Ship both hooks in the integration module, built from one function so they cannot disagree:

from grelmicro.http import RouteDeclaration


def route_declarations(app):
    return [_declare(route) for route in app.routes]


def install_route_gate(app, gate):
    for route in app.routes:
        route.app = gate(route.app, _declare(route))


def _declare(route):
    return RouteDeclaration(
        route.path,
        methods=frozenset(route.methods),
        anonymous=route.anonymous,
        scopes=frozenset(route.scopes),
    )

install_route_gate replaces what the router dispatches to with the app the gate returns. Declare the routes lists every field and rule.

The cache, idempotency and conditional requests answer at the route

CachedResponses, IdempotentRequests and ConditionalRequests run at the route on every app.

  • A URL no route answers is neither cached nor replayed, such as the 200 of a not-found handler of your own. Declare a route for what has to be cached or replayed.
  • A HEAD is read from the stored GET only on a route that answers both.
  • require_precondition= and require_key=True answer 428 and 400 only on a route that takes the request. An unknown URL gets 404, and a method the route does not take gets 405.
  • An include= pattern never caches a route under middleware on a mount, a router or a mounted app. Declare CachedResponse() on such a route when its response is the same for every caller.

The rate limit still counts every request in front of the router.

A middleware added by hand reads no route

CachedResponsesMiddleware, IdempotencyMiddleware and ConditionalRequestsMiddleware added by hand no longer read the app's routes. Each one decides from its own include= and exclude= and the request headers. A request carrying Authorization or Cookie still skips the cache and the default idempotency key, and a response setting a cookie is still not stored. A route's own checks, such as an authentication dependency or middleware on a mount that reads a tenant header, are not seen there, and neither is its CachedResponse(). Register the component, which reads them:

# Before
app.add_middleware(
    CachedResponsesMiddleware, cache=TTLCache(), include={"/products/*": 60}
)

# After
micro = Grelmicro(uses=[..., CachedResponses(include={"/products/*": 60})])
micro.install(app)

Or keep it added by hand and leave such routes out of include=. CachedResponsesMiddleware(policies=...) is removed, so drop it.

CacheError is removed

Nothing raised CacheError, so an except CacheError: block never ran. Delete it. A backend failure reaches the caller as the backend's own error, such as redis.exceptions.ConnectionError.

lock= names how far misses fold

@cached and TTLCache.get_or_set take the same lock=, a backend scope:

Before After
@cached(cache, lock="local") @cached(cache, lock="process"), the default
@cached(cache, lock=True) @cached(cache, lock="cluster")
@cached(cache, lock=False) @cached(cache, lock=None)
cache.get_or_set(key, factory) cache.get_or_set(key, factory, lock="cluster") to keep folding across replicas

"host" and "cluster" fold through the lock backend of the app's Coordination. Register one, or the call raises OutOfContextError. A lock backend that reaches less far than the scope is refused in staging and production, like requires=.

max_wait= bounds every wait to get in

Idempotency spells its wait the way the bulkhead and the rate limiter do:

Before After
async with idem(key, wait_timeout=5) async with idem(key, max_wait=5)
idem.run(key, fn, wait_timeout=5) idem.run(key, fn, max_wait=5)
IdempotentRequests(wait_timeout=5) IdempotentRequests(max_wait=5)
GREL_IDEMPOTENT_REQUESTS_WAIT_TIMEOUT GREL_IDEMPOTENT_REQUESTS_MAX_WAIT
BulkheadConfig(max_wait=None) BulkheadConfig(max_wait=0), the default

A lock keeps timeout= on acquire() and hold().

Idempotency checks its lock, and CachedResponses names its fold

A duplicate request waits on a lock so it runs once. Without a lock backend that lock holds in one process only, and a duplicate on another replica runs again. The backend check now says so, like it does for the cache. Register a lock backend, or say one replica is all you run:

micro = Grelmicro(uses=[Cache(redis), Coordination(redis), IdempotentRequests()])
# or
micro = Grelmicro(uses=[Cache(redis), IdempotentRequests(requires="process")])

CachedResponses folds concurrent misses in the process by default, like @cached. Pass CachedResponses(lock="cluster") to fold them through the lock backend, as it did whenever one existed.

Extend a lease with extend()

TaskLock.refresh() is now TaskLock.extend(), like Lock and ReadWriteLock:

fragment
# Before
await task_lock.refresh()

# After
await task_lock.extend()

A backend failure while extending a lease raises LockExtendError, on every lock and when a task gate extends its claim. Lock and ReadWriteLock raised LockAcquireError there, and TaskLock raised LockReleaseError. except LockBackendError catches every backend failure.

A lost lease still raises LockNotOwnedError, which is no longer a LockReleaseError or a LockBackendError. Code that used except LockReleaseError to also catch a lost lease adds except LockNotOwnedError.

The grelmicro.lock.renewals metric is now grelmicro.lock.extensions. Switch dashboards and alerts on the old name to the new one.

key= is always a function

key= now takes the function deriving the key, everywhere. key_maker= is gone. @cached and @limiter take a template string as key_template=, and a string passed to key= raises TypeError:

# Before
@cached(cache, key="user:{user_id}")
@cached(cache, key_maker=lambda func, args, kwargs: f"user:{args[0]}")
@limiter(key="user:{user_id}")
@limiter(key_maker=lambda func, args, kwargs: f"user:{args[0]}")
IdempotentRequests(key_maker=tenant_key)

# After
@cached(cache, key_template="user:{user_id}")
@cached(cache, key=lambda func, args, kwargs: f"user:{args[0]}")
@limiter(key_template="user:{user_id}")
@limiter(key=lambda func, args, kwargs: f"user:{args[0]}")
IdempotentRequests(key=tenant_key)

IdempotencyMiddleware(key_maker=...) is IdempotencyMiddleware(key=...) the same way. IdempotencyKeyMakerError is renamed IdempotencyKeyFunctionError.

A TaskLock needs a name

TaskLock() has no name by default. As the gate of a task it takes the task name, as before. Used on its own, it raises SettingsValidationError when entered. Pass the name it used to share:

fragment
# Before
task_lock = TaskLock()

# After
task_lock = TaskLock("cleanup")

A gate named TaskLock("default") keeps that name instead of taking the task name. Drop the name to have it take the task name.

TaskLock, LeaderElection and a gated task's name= follow the Lock rule: a letter or digit, then letters, digits and ._:/-, up to 200 characters. Rename one that does not, such as name="daily report" to name="daily-report". A gated task's name= cannot start with task- either.

A task named after its function, whose name is not a valid lock name, locks under a derived name. In a script run directly, __main__:job locks under task-__main__:job. That is also its gate log label and its grelmicro.lock.name metric label, so update dashboards that filter on the old one. Workers on the previous version lock it under the old key and do not block the new ones, so upgrade every worker running such a task at once, as State and rolling deploys explains.

Provider methods end in _backend

A provider method that builds a backend now says so. Coordination takes and exposes its backends under the same names:

Before After
provider.lock() provider.lock_backend()
provider.readwritelock() provider.readwritelock_backend()
provider.leaderelection() provider.leaderelection_backend()
provider.schedule() provider.schedule_backend()
provider.cache() provider.cache_backend()
provider.outbox() provider.outbox_backend()
provider.ratelimiter() provider.ratelimiter_backend()
provider.circuitbreaker() provider.circuitbreaker_backend()
coordination.rwlock_backend coordination.readwritelock_backend
coordination.election_backend coordination.leaderelection_backend
Coordination(rwlock=...) Coordination(readwritelock=...)
Coordination(election=...) Coordination(leaderelection=...)
grelmicro.coordination.election.adapters grelmicro.coordination.leaderelection.adapters
fragment
# Before
leader = LeaderElection("worker", backend=redis.leaderelection())

# After
leader = LeaderElection("worker", backend=redis.leaderelection_backend())

A custom Provider renames the methods it overrides the same way, such as def lock_backend(self, **kwargs). A subclass that still defines lock() without lock_backend() fails when the class is defined, naming the rename. Component methods are unchanged: micro.coordination.lock("cart") still returns a Lock, and coordination.lock_backend and coordination.schedule_backend keep their names.

A plugin that ships a leader election adapter registers it under the grelmicro.coordination.leaderelection.adapters entry-point group.

Full list: 0.42.0 and 0.42.1 in the changelog

0.41

Changes that do not raise

  • Metric labels gain the grelmicro. prefix, and some metrics change name. A dashboard or an alert on the old names goes blank. Update the queries.
  • /livez, /readyz, /healthz and /metrics leave the OpenAPI schema. Pass include_in_schema=True.
  • JSON logs use orjson when it is installed, which renders some values differently. Pin the serializer.
  • An idempotent request carrying Authorization or Cookie, on an authenticated Starlette scope, or to a FastAPI route with dependencies runs the app every time. Entries stored before 0.41 are not read. Pass a key function.
  • Uvicorn's own lines move from stderr to the stream the app writes to. Turn the pass off.
  • A decorator on an object with an async __call__ now applies to the call. Nothing to change.
  • A cached(ttl=...) decorator applied to several functions builds one cache per function. Pass a TTLCache to share one.
  • CachedResponses(vary_by_query=None) keys on the whole query string, whatever the environment says. Leave it unset to read the environment.
  • IdempotencyMiddleware answers 400, 409, 413 and 422 as application/problem+json. A client reading detail still finds it. Nothing to change.
  • An Idempotency-Key holding a byte outside printable ASCII is answered 400. Send printable ASCII keys.
  • A problem detail never carries the exception message, and every error response carries Cache-Control: no-store. Nothing to change.
  • Access logs keep query parameter names but mask every value. Nothing to change.
  • A bounded lock acquire that runs out raises LockTimeoutError and answers 503, not 500. except TimeoutError still catches it. Nothing to change.
  • Timeout raises DeadlineExceededError, which except TimeoutError still catches. Nothing to change.
  • TaskRouter(tasks=[...]) and Tasks(tasks=[...]) copy the list they are given. Add tasks to the router, not the list.
  • A Bulkhead's uses= answers before micro.fake() and micro.override(...). Build the bulkhead with the test backends.

Changes that raise

Metric names and labels

Every metric attribute grelmicro sets carries the grelmicro. prefix. outcome, result and decision become one attribute, grelmicro.outcome. task.name, check.name, retry.name, circuit_breaker.name, bulkhead.name, timeout.name, rate_limiter.name, idempotency.name and topic gain the prefix too. error.type is unchanged.

A query on an old label matches nothing and raises no error. Prometheus writes the dots as underscores, so the task_name label becomes grelmicro_task_name.

Some metrics change name or meaning:

0.40 0.41
grelmicro.bulkhead.rejections grelmicro.bulkhead.calls, grelmicro.outcome is admitted or rejected
grelmicro.rate_limiter.decisions, decision is allowed or limited grelmicro.rate_limiter.calls, grelmicro.outcome is admitted or rejected
grelmicro.circuit_breaker.transitions with from and to the same metric with grelmicro.circuit_breaker.previous_state and grelmicro.circuit_breaker.state
grelmicro.retry.attempts, one point per run one point per attempt
# Before
rate(grelmicro_rate_limiter_decisions_total{decision="limited"}[5m])

# After
rate(grelmicro_rate_limiter_calls_total{grelmicro_outcome="rejected"}[5m])

grelmicro.task.duration and grelmicro.retry.duration carry grelmicro.outcome. grelmicro.cache.operations, .stale_serves, .early_refreshes and grelmicro.shield.cache_writes carry the instance name, so two caches report two series. Metrics lists every name.

Health and metrics routes leave the OpenAPI schema

health_router() keeps /livez, /readyz and /healthz out of the OpenAPI schema, and metrics_router() keeps /metrics out. The routes answer as before. Only the schema changes, so a contract diff or a generated client sees them go. Pass include_in_schema=True to publish them:

# Before: published by default
app.include_router(health_router())
app.include_router(metrics_router())

# After
app.include_router(health_router(include_in_schema=True))
app.include_router(metrics_router(include_in_schema=True))

A published route is described as what it sends. /livez and /readyz carry no content, and /metrics carries Prometheus text. Both used to read as JSON.

JSON logs use orjson when it is installed

GREL_LOG_JSON_SERIALIZER defaults to auto, where it was stdlib. auto uses orjson when it is installed and the standard library when it is not. grelmicro[standard] installs orjson. This reverses 0.35.1, which kept stdlib and did not pick orjson just because it was installed.

The two write some values differently. orjson writes a UUID, an Enum and a dataclass as their value, and non-ASCII text as UTF-8. The standard library writes their repr and escapes non-ASCII text. With orjson, NaN and Infinity become null. A parser or an alert matching the exact text of such a field can stop matching. Pin the serializer to keep the 0.40 output:

GREL_LOG_JSON_SERIALIZER=stdlib

The variable applies with GREL_ENV_LOAD on. In code, pass configure(json_serializer="stdlib"). Which serializer you get compares the two.

An authenticated request is never replayed

Without a key function of your own, the default idempotency key no longer replays a response across callers. These requests skip idempotency and run the app every time:

  • a request carrying Authorization or Cookie
  • a request on an authenticated Starlette scope
  • a FastAPI route with dependencies, such as authentication that reads a custom header

A required key is still checked first, so require_key=True still answers 400 without one. To replay authenticated requests, fold the caller's identity into a key function, as Keys are scoped per route shows. In 0.41 it is key_maker=, and from 0.42 it is key=.

The built-in key moves to a new v3: format, so 0.41 does not read an entry an older version stored. A request a client retries across the upgrade runs again. A key function of your own keeps its keys.

From 0.42, the default key follows the checks each route declares, and a FastAPI route with only grelmicro's own dependencies replays again.

Uvicorn writes to the app's stream

configure() moves uvicorn's own lines from sys.stderr to the stream the rest of the process writes to. A shell redirect such as 2> that kept uvicorn's startup and error lines apart no longer catches them. A handler already pointed elsewhere keeps its stream, and a FileHandler keeps its file. Pass configure(uvicorn_enabled=False) to leave uvicorn's logging alone.

Decorators read an async callable object as async

Every decorator now reads an object whose __call__ is async as async. Before, inspect.iscoroutinefunction reported False for one, so the decorator built its sync wrapper. That wrapper called the object, took the coroutine it returned as the result, and returned. The body ran later, when your code awaited that coroutine, outside the policy.

class Client:
    async def __call__(self, order_id: str) -> Order:
        ...


fetch = retry(when=ConnectionError, attempts=3)(Client())

The retry above made one attempt, not three, and the first failure reached the caller with no backoff and no budget.

Each decorator showed it differently, which is why the symptom table above lists four rows:

  • @retry and @fallback returned without engaging, silently. @measure and @instrument timed the creation of the coroutine rather than the call, so a slow body recorded almost no time and a failing one recorded no error.
  • @timeout, @bulkhead and @shield refused the object at decoration, with a TypeError saying they only decorate async functions.
  • @cached(ttl=...) refused it too, with its own wording: supports async functions only. @cached on a TTLCache you passed did not refuse, and raised AttributeError: '...' object has no attribute '__qualname__' instead.
  • CircuitBreaker raised EventLoopDeadlockError, because the sync path it took is the one meant for a worker thread and it saw the event loop on the other side. That error is a BaseException, so an except Exception around the call did not catch it.

Nothing in your code has to change. A decorator applied to a plain async function, which is how nearly everyone writes this, was never affected.

The behaviour of a running deployment changes only where the failure was silent, because the loud ones stopped the app from starting at all. So look at @retry, @fallback, @measure and @instrument on a callable object: a retry that never fired starts firing, and a call that recorded no time starts recording it.

Full list: 0.41.0 in the changelog

0.40

Changes that do not raise

  • An error no longer quotes the value it rejected, an unknown timezone name included. It names a close match instead. Nothing to change.

Changes that raise

  • The per-module *SettingsValidationError classes are gone, and Fallback, Shield, TTLCache, cached(), TrustedProxies and ExternalConfig raise SettingsValidationError. Catch the base error.
  • Grelmicro(environment=...) refuses a value outside the four tiers. Name a real tier.
  • A bad Match argument or when= raises ValueError, not TypeError. Catch ValueError.
  • RedisProviderConfigError and PostgresProviderConfigError are removed. Catch the base error.

One error for every bad configuration value

A bad configuration value raises SettingsValidationError, whichever pattern or component you built. The ten per-module subclasses are gone: CacheSettingsValidationError, CoordinationSettingsValidationError, HealthSettingsValidationError, IdempotencySettingsValidationError, LogSettingsValidationError, MetricsSettingsValidationError, OutboxSettingsValidationError, ResilienceSettingsValidationError, TaskSettingsValidationError, and TraceSettingsValidationError.

Catch the base error, which every one of them already subclassed:

# Before
from grelmicro.log import Log, LogSettingsValidationError

try:
    Log(level="NOPE")
except LogSettingsValidationError:
    ...

# After
from grelmicro import SettingsValidationError

try:
    Log(level="NOPE")
except SettingsValidationError:
    ...

except ValueError and except GrelmicroError keep working unchanged.

The same applies to cached(), TrustedProxies, and ExternalConfig, which raised a bare ValueError or TypeError. cached(ttl=-1) already raised SettingsValidationError while lock=, early=, and stale_ttl= did not, so one call had two contracts.

A refused name moved the same way: a bad Lock name, an adapter table_name, or a Redis prefix that cannot survive a cluster. The name is still repeated in the message, since it is a literal you wrote in code rather than a value read from a variable.

Fallback, Shield, and TTLCache used to let pydantic's ValidationError through instead, so they now raise SettingsValidationError too. If you catch ValidationError around one of those, catch SettingsValidationError. It subclasses ValueError, which ValidationError also is, so an except ValueError around either keeps working.

That change closes a leak: pydantic attaches the rejected input to its error, so an invalid value read from the environment used to reach the traceback. Errors now carry the variable name and the reason, never the value.

An unknown environment is refused

Grelmicro(environment=...) stored whatever it was given. A value outside the four tiers was accepted in silence, and the backend check then ran as if no tier had been declared, which is the check that refuses a memory backend in production. It now raises:

# Before: accepted, and the backend check quietly went soft
Grelmicro(environment="prod")

# After
Grelmicro(environment="production")

The four tiers are development, test, staging, and production. GREL_ENVIRONMENT already warned on an unknown value, so this makes the two doors agree.

A bad Match argument raises ValueError

Match.exception() and Match.exception_cause() raised TypeError when an argument was not an exception class, and when they got no argument at all. Match.exception_message() raised TypeError when it got both contains= and regex=, or neither. when= raised TypeError for a value that was not a Match, an exception class, a tuple of them, or a callable. They raise ValueError now, and so do Match.predicate() for a non-callable and Match.exception_message() for a contains= that is not a string or a regex= that is neither a string nor a compiled pattern:

# Before
try:
    Match.exception(ValueError, "nope")
except TypeError:
    ...

# After
try:
    Match.exception(ValueError, "nope")
except ValueError:
    ...

pydantic converts only ValueError and AssertionError into a validation error, so the old TypeError escaped except SettingsValidationError and except ValueError alike when the same value arrived through GREL_RETRY_{NAME}_WHEN. One empty entry in a mounted ConfigMap was enough to abort a whole reload cycle.

The provider error subclasses are gone

RedisProviderConfigError and PostgresProviderConfigError are removed. A provider raises SettingsValidationError, like every other class:

# Before
from grelmicro.providers.redis import RedisProviderConfigError

try:
    RedisProvider("anything://localhost:6379")
except RedisProviderConfigError:
    ...

# After
from grelmicro import SettingsValidationError

try:
    RedisProvider("anything://localhost:6379")
except SettingsValidationError:
    ...

They were the last two per-module subclasses, left behind when the other ten went. except ValueError and except GrelmicroError keep working unchanged.

Full list: 0.40.0 in the changelog

0.39

Changes that do not raise

  • A filter, a level or a handler on the grelmicro.clientip logger stops matching. Rename the logger.
  • With GREL_ENV_LOAD on, CircuitBreaker, RateLimiter and the Shield factories read their GREL_* variables at construction. A variable they ignored now applies. Check the GREL_* variables.

Changes that raise

grelmicro.clientip moved to grelmicro.security

Client IP resolution is one of the checks a service runs on an inbound request, so it now lives with them. The names and their behaviour are unchanged. Rename the import:

# Before
from grelmicro.clientip import TrustedProxies, resolve_client_address

# After
from grelmicro.security import TrustedProxies, resolve_client_address

The logger moved with it, from grelmicro.clientip to grelmicro.security.clientip. A filter, a level, or a handler attached to the old name stops matching, and silently, because nothing logs on that name any more. Rename it wherever your logging config names it.

Full list: 0.39.0 in the changelog

0.38

Changes that do not raise

  • A variable under the bare GREL_{PATTERN}_ prefix applies to every instance, not only the default one. Name the default instance.

Changes that raise

None.

A bare variable reaches every instance

GREL_LOCK_LEASE_DURATION=60 used to configure Lock("default") alone. It now sets the lease of every Lock that does not set its own GREL_LOCK_{NAME}_LEASE_DURATION. Every pattern reads its bare prefix the same way. To keep a value on the default instance only, name it:

# Before
GREL_LOCK_LEASE_DURATION=60

# After
GREL_LOCK_DEFAULT_LEASE_DURATION=60

Full list: 0.38.0 in the changelog

0.37

Changes that do not raise

None.

Changes that raise

  • RateLimiterRegistry and CircuitBreakerRegistry are renamed, and health_router(registry=...) takes component=. Rename to Component.
  • From 0.37.1, a provider URL is checked on every path, so a URL a client library accepted can be refused at construction. Fix the URL.

Registry classes are now Component

RateLimiterRegistry is RateLimiterComponent and CircuitBreakerRegistry is CircuitBreakerComponent. Neither ever registered anything: each wraps one backend. Rename the import and the call:

# Before
from grelmicro import Grelmicro
from grelmicro.resilience import CircuitBreakerRegistry, RateLimiterRegistry

micro = Grelmicro(
    uses=[RateLimiterRegistry(redis), CircuitBreakerRegistry(redis)]
)


# After
from grelmicro.resilience import CircuitBreakerComponent, RateLimiterComponent

micro = Grelmicro(
    uses=[RateLimiterComponent(redis), CircuitBreakerComponent(redis)]
)

Most wiring needs neither name. Grelmicro(uses=[redis]) registers a component for every kind the provider serves, so name the class only for a second instance or for micro.override(...).

health_router(registry=...) is now health_router(component=...), matching metrics_router(component=...):

# Before
app.include_router(health_router(registry=health))

# After
app.include_router(health_router(component=health))

A provider URL is validated on every path

From 0.37.1, a URL passed to a provider constructor is checked against the same type as one read from the environment. Both paths accept the same URLs and raise the same error, so a URL the client library used to accept can now be refused where it used to connect:

# Before: redis-py accepted the authority and connected to h1 alone
RedisProvider("redis+sentinel://h1:26379,/mymaster")

# After
# SettingsValidationError: Could not validate settings:
# - url: Input should be a valid URL, empty host

The message names what is wrong with the URL. Fix the URL, most often a trailing comma in a multi-host authority, a missing host, or a port that is not a number.

Nothing else moves: redis://, rediss://, unix://, redis+sentinel://, redis+cluster://, the four valkey spellings, and every Postgres scheme including the SQLAlchemy driver forms are all accepted as before.

Full list: 0.37.0 and 0.37.1 in the changelog

0.36

Changes that do not raise

None.

Changes that raise

  • LogTimeZoneType is removed. Use TimeZoneName.
  • A timezone abbreviation such as CEST is refused where the value is read. Name the zone.
  • Starting Tasks twice raises TaskStartOperationError instead of TaskAddOperationError. Catch TaskStartOperationError.
  • A negative Tasks(shutdown_timeout=...) raises TaskSettingsValidationError instead of a plain ValueError. It still subclasses ValueError. Catch ValueError.

LogTimeZoneType is gone

Use TimeZoneName from grelmicro.types, which every component that takes a timezone now shares:

# Before
from grelmicro.log import LogTimeZoneType

# After
from grelmicro.types import TimeZoneName

A timezone abbreviation no longer validates

GREL_LOG_TIMEZONE=CEST used to validate and then fail later, because zoneinfo has no such zone. It now raises SettingsValidationError: unknown timezone name where the value is read. The message does not repeat the value, so the variable name is what locates it. Abbreviations such as CEST, PST, PDT, EDT, BST, and JST are daylight saving variants, not zones, and pinning one would freeze the offset year-round. Name the zone instead:

# Before
GREL_LOG_TIMEZONE=CEST

# After
GREL_LOG_TIMEZONE=Europe/Zurich

Real zone names that look like abbreviations keep working, starting with the default UTC, and including CET, EET, GMT, EST, MST, and HST.

Full list: 0.36.0 in the changelog

0.35

Changes that do not raise

Changes that raise

/healthz leaves out empty fields

A passing check 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 on every check treats 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 every entry still carries status and critical.

A Component claims only its own kind

Listing any Component used to turn every provider default off. An app that added a HealthChecks lost its cache and its locks with no warning. Now a Component claims its own kind and the Provider fills the rest. A Component of a kind no Provider serves, such as HealthChecks, Log or Trace, claims nothing. Two or more Providers still fill no defaults.

If you listed every component only to work around that, the short form works:

micro = Grelmicro(uses=[redis, tasks, health])

A Redis password beside the URL is used

REDIS_PASSWORD next to a REDIS_URL was read and then dropped, so the client connected without it. It now authenticates. redis+sentinel:// and redis+cluster:// are accepted from the environment and from RedisConfig, the multi-host form included:

REDIS_URL=redis+sentinel://a:26379,b:26379/mymaster/0
REDIS_PASSWORD=...

A URL that already carries credentials, beside a separate REDIS_PASSWORD, now raises. Keep the password in one place. The same applies to VALKEY_*.

Uvicorn's lines follow your log format

From 0.35.1, configure() applies your format to uvicorn's loggers. A line that looked like this:

INFO:     127.0.0.1:54321 - "POST /orders HTTP/1.1" 200 OK

now carries a timestamp, a level field and structured request fields, like the rest of the app. Update a pipeline that parses uvicorn's plain format, or keep the old lines:

configure(uvicorn_enabled=False)

An ignored GREL_* variable warns

From 0.35.1, a GREL_* variable set without GREL_ENV_LOAD warns GrelmicroConfigWarning. A suite run with -W error, or pytest with filterwarnings = error, fails on it. Fix the configuration:

GREL_ENV_LOAD=1

Or pass the value in code, which never needs the flag. To keep the warning visible without failing the build, filter the category:

filterwarnings = ["error", "ignore::grelmicro.GrelmicroConfigWarning"]

Full list: 0.35.0 and 0.35.1 in the changelog

0.34

Changes that do not raise

  • grelmicro.task.runs counts every fire, so its total grows with the number of workers. Filter on the outcome.

Changes that raise

  • @cached on a method without a key raises TypeError at decoration. Name the key.

@cached on a method needs an explicit key

Decorating a method without key= or key_maker= now raises TypeError at decoration time, so the failure lands at import rather than on a request.

The default key is the repr() of every argument, and on a method the first one is self. That read two ways, both wrong. Two instances whose repr() matched shared one entry, so a call on one returned the other's value. An instance using the default repr() carried a memory address, so its key changed on every restart and the entry was never found again.

Name what identifies the entry and leave self out:

# Before
class Repo:
    @cached(cache)
    async def load(self, user_id: int) -> User: ...


# After
class Repo:
    @cached(cache, key="repo:{user_id}")
    async def load(self, user_id: int) -> User: ...

When the result does depend on instance state, fold that state into the key:

@cached(cache, key="repo:{self.region}:{user_id}")
async def load(self, user_id: int) -> User: ...

A staticmethod and a classmethod are untouched. Neither receives an instance, so their default key was already sound.

Entries written before the upgrade are not reachable under the new key, so expect one cold period for the functions you change.

Task run totals count every fire

grelmicro.task.runs now counts every fire, not only the fires that ran the body. A fire a peer took counts as skipped, a fire dropped past its grace budget as missed, and a fire that never got there because coordination failed as coordination_error.

That makes the bare total larger. On a fleet of N workers sharing a lock it is now roughly N times the number of fires, because the N-1 workers that stand down each count their fire.

If a chart or an alert reads the total as "how often does my task run", filter it:

# Before
rate(grelmicro_task_runs_total[5m])

# After
rate(grelmicro_task_runs_total{grelmicro_outcome="success"}[5m])

The values keep the meanings they had. The label itself was renamed from outcome to grelmicro_outcome in a later release, and the query above already carries the new spelling.

Full list: 0.34.0 in the changelog

0.33

Changes that do not raise

None.

Changes that raise

None.

Full list: 0.33.0 in the changelog

0.32

Changes that do not raise

  • url and endpoint fields read back as SecretUrl, and each headers value as SecretStr. Call .get_secret_value().
  • From 0.32.2, an open circuit on Postgres or SQLite closes once after the upgrade. Nothing to change.

Changes that raise

  • SQLiteLockAdapter and SQLiteScheduleAdapter take provider=, not a path. Pass provider=.

SQLite adapters take a provider, not a path

SQLiteLockAdapter and SQLiteScheduleAdapter now take provider=, like every other SQLite adapter.

# Before
SQLiteLockAdapter("app.db")

# After
SQLiteLockAdapter(provider=SQLiteProvider("app.db"))

Better still, pass the provider to the component and let it build both:

sqlite = SQLiteProvider("app.db")
micro = Grelmicro(uses=[Coordination(sqlite)])

That also shares one connection across every component on the same file, where the old form opened its own.

A missing path raises SettingsValidationError, as every configuration failure does since 0.40.

URL and header fields hide their credentials

url on PostgresConfig and RedisConfig, and endpoint on TraceConfig and MetricsConfig, are now SecretUrl. Each headers value on TraceConfig and MetricsConfig is now a SecretStr.

Passing a plain string still works. Only reading the value back changes:

# Before
dsn = config.url

# After
dsn = config.url.get_secret_value()

Nothing changes on the wire. The point is that repr(), model_dump() and a ValidationError no longer carry the password.

An open circuit closes once

From 0.32.2, circuit breakers reclaim their stored state instead of keeping it forever. Rows written before 0.32.2 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.

Full list: 0.32.0 and 0.32.2 in the changelog

0.31

Changes that do not raise

Changes that raise

None.

Credential fields hide their value

basic_auth_password on TraceConfig and MetricsConfig, and password on PostgresConfig and RedisConfig, are now SecretStr. Same shape as the 0.32 change above: passing a plain string still works, reading it back needs .get_secret_value().

Metrics() no longer defaults to localhost

Metrics() now defaults to the auto exporter. With an endpoint configured it exports over OTLP HTTP. Without one it auto-disables into a true no-op, where it previously fell back to localhost:4318.

This one fails quietly. If you relied on the implicit localhost default, metrics simply stop arriving and nothing raises. Set the endpoint explicitly:

Metrics(endpoint="http://localhost:4318")

Or from the environment, GREL_METRICS_ENDPOINT.

The upside is that you can now register Metrics() unconditionally: an auto-disabled Metrics installs no provider and never conflicts with a second app.

Full list: 0.31.0 in the changelog

0.30

Changes that do not raise

None.

Changes that raise

  • Operation.response is now a result() method. Use result().

Operation.response became result()

The idempotency Operation.response attribute is now a result() method, typed as the stored type so a replay branch returns it without a cast.

fragment
async with idem(key) as op:
    if op.replayed:
        return op.result()  # was: op.response
    ...

It is valid only on a replay. Calling it on a first execution raises IdempotencyStateError, so keep it behind if op.replayed:.

Full list: 0.30.0 in the changelog

About deprecation

A change in behavior or a rename warns for one minor release before it lands. The warning ships in one minor, such as 0.43.0, and the change in the next, such as 0.44.0. A patch release never adds a warning.

A changed default warns with GrelmicroFutureWarning while you leave the argument at its default. The message names the release that switches it, the argument that keeps today's behavior, and the argument that adopts the new one. Pass either argument and the warning stops. Your code then behaves the same before and after the switch.

A renamed name ships beside the old one. The old name warns with GrelmicroFutureWarning for one minor release and is removed in the next.

The changelog lists the warning under Deprecated in the release that adds it, and the switch under Breaking in the release that makes it. Each breaking release has a note on this page.

To catch every announced change in your test suite, promote the warning to an error:

[tool.pytest.ini_options]
filterwarnings = ["error::grelmicro.GrelmicroFutureWarning"]

Each switching release also has its own category, such as Grelmicro045Warning for the changes that land in 0.45.0. Filter on it to catch one release's changes and leave the others alone:

[tool.pytest.ini_options]
filterwarnings = ["error::grelmicro.Grelmicro045Warning"]