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.exporterorLogConfig.levelreads back as a plainstr, not as its old enum member.==,str()and the JSON dump are unchanged. Compare with the string. - A
HealthCheckswith noprovider_checks=beside a Provider shows each Provider in/healthzas a non-criticalprovider:{short_name}check./readyzis unchanged. Pick a mode. - A
HealthCheckswith noprovider_checks=, entered withasync withinside an open app rather than listed inuses=, now registers a check for each Provider on that app. Pick a mode. - Under
micro.fake(), aHealthCheckswith noprovider_checks=now shows oneprovider:memorycheck for the fake's store, whichever Provider the app lists. Pick a mode. LogConfig.formatstill reads back in uppercase, such as"JSON". Comparing it with a format name now warns withGrelmicro045Warning. 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
LogFormatvalues 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 withGrelmicro045Warning. It is removed in 0.45.0. Omit the argument for"report", or pass"off"to register none.@cached(lock="host")andlock="cluster"are checked when the app opens. A fold with noCoordination, or one whose lock backend falls short, now warns withBackendScopeWarningon startup, instagingandproductiontoo. A miss behaves as in 0.43. Register aCoordination.- A
GREL_*variable such asGREL_OPS_PORT, set whileGREL_ENV_LOADis unset, is now read. 0.43 ignored it and warned withEnvLoadDefaultWarning. SetGREL_ENV_LOAD=0to keep it unread. EnvLoadDefaultWarningis no longer emitted. Reading it warns withGrelmicro045Warning, and it is removed in 0.45.0. Filter onEnvLoadOffWarning.- A name that
grelmicro.integrations.fastapi,grelmicro.integrations.starletteorgrelmicro.integrations.litestaronly imported in 0.43, such asRateLimiterorErrorResponses, still imports and now warns withGrelmicro045Warning. It leaves the module in 0.45.0. Import it from the module the warning names, such asgrelmicro.resilience.AdmissionErrorfromgrelmicro.security.bansandGrelmicroErrorfromgrelmicro.security.jwtwork the same way. Import them fromgrelmicro.errors. gate-backendstays a warning instagingandproduction. It says the check fails there from 0.45.0 and warns withGrelmicro045Warning. Give each gated task a backend.
Changes that raise
MetricsExporterType,TraceExporterType,TraceProcessorType,TraceSamplerType,LogLevelType,LogFormatType,LogBackendTypeandLogSerializerTypeare removed. Pass the string..valueand.nameon a mode field raiseAttributeError. Compare with the string.HealthChecks(auto_health=)andHealthChecks.from_config(auto_health=)raiseTypeError. Passprovider_checks=.GREL_ENVIRONMENT=Production, or any tier in another case, declares that tier and no longer warns. Instagingandproductionan unmet backend scope then raisesBackendScopeErrorat startup. Check the 0.43 warning.- A
GREL_*variable with an invalid value, such asGREL_OPS_PORT=abc, raisesSettingsValidationErrorwhen the component is built, since the variable is now read. Fix the variable or setGREL_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.exporterorLogConfig.levelstill reads back as its enum member, which equals the string. From 0.44.0 it reads back as a plainstr, so.value,.nameandisagainst a member stop working..valueand.namenow warn withGrelmicroFutureWarning. Compare with the string. MetricsExporterType,TraceExporterType,TraceProcessorType,TraceSamplerType,LogLevelType,LogFormatType,LogBackendTypeandLogSerializerTypewarn withGrelmicroFutureWarningand 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 withGrelmicroFutureWarning. From 0.44.0 it declares that tier. Spell the tier in lowercase.- A
GREL_*variable such asGREL_OPS_PORT, set whileGREL_ENV_LOADis unset, is still not read and now warns withEnvLoadDefaultWarning, aGrelmicroFutureWarningthat is also anEnvLoadOffWarning, so a filter onEnvLoadOffWarningstill silences it. From 0.44.0 it is read. SetGREL_ENV_LOAD. HealthChecks(auto_health=True)andauto_health=Falsestill work and now warn withGrelmicroFutureWarning, and type checkers report them as deprecated.auto_health=is removed in 0.44.0. Passprovider_checks=.- A
HealthCheckswith noprovider_checks=beside a Provider still registers no provider checks, and now warns withGrelmicroFutureWarningon startup. From 0.44.0 it shows each Provider in/healthz. Passprovider_checks=. - A choice in another case is accepted where it used to be refused, such as
requires="Cluster",lock="Process",gate="Claim"orenvironment="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
crontask with nogate=runs each fire on every worker. Passgate="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=1keeps outbox rows for one second, not for good. WriteNone.TTLCache.get_or_setfolds concurrent misses in one process, not across replicas. Passlock="cluster".CachedResponsesfolds concurrent misses in one process, not across replicas. Passlock="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.renewalsmetric is nowgrelmicro.lock.extensions. Follow theextend()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.durationin milliseconds becomeshttp.server.request.durationin seconds, and the body size metrics are gone. FastAPI records the request spans and metrics itself. Readhttp.server.request.duration, in seconds. - On FastAPI,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_*andOTEL_SEMCONV_STABILITY_OPT_INno 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, atypother thanJWT,JOSEorat+jwt, or acnfclaim is rejected. Amalformedtoken no longer counts toward a ban. Issue tokens withsuband a plaintyp. grelmicro checkandmicro.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_EXCEPTIONSandGREL_SHIELD_{NAME}_TIMEOUT_ERRORSare 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_TIMEOUTandGREL_IDEMPOTENT_REQUESTS_{NAME}_WAIT_TIMEOUTare ignored without an error. From 0.42.2 they raise. Rename the variable to_MAX_WAIT. - A
HEADis answered from the storedGETonly on a route that answers both, and aHEADroute of its own runs its handler. Declare the route. - With
TraceorMetricsregistered, FastAPI no longer exports anything itself, and its log records are not exported. Export throughTraceandMetrics. - On FastAPI, a request span follows the semantic conventions 1.44 instead of 1.11. It has
fastapi.dependencies,fastapi.endpointandfastapi.serializationchildren instead ofsendandreceive, and a WebSocket span is named likeWS /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
Hostroute and a prefix a proxy stripped. Update log filters. - On a plain Starlette app,
http.routecarries the route template in the access log and security events. Update log filters. grelmicro check,micro.describe(app)andCachedResponsesmatch aninclude=URL one path segment per parameter, whatever its converter, so/items/abcnames/items/{item_id:int}. Check theinclude=patterns.ClientBannedErroris anAdmissionErrorcarryingretry_after, andErrorResponsesanswers it429with aRetry-Afterheader. Catch it as anAdmissionError.- From 0.42.1, on Starlette, a route under a
Mountnames every mount, such as/mm/r/ainstead of/mm/a. Update route filters.
Changes that raise
- A float duration is refused, and
every(seconds=...)andmisfire_grace_seconds=are renamed. Pass whole seconds or atimedelta. everyandcronrefuselock=andleader=, and aTaskLockgate that holds for less than the interval is refused. Passgate=.ignore_exceptions=,timeout_errors=and the bare@shieldare gone, and a Shield requireswhen=. Passwhen=.- A Shield refuses to build while
GREL_SHIELD_{NAME}_PROFILEorGREL_SHIELD_PROFILEis set. Choose the preset in code. - From 0.42.2, with env reads on,
GREL_CIRCUITBREAKER_{NAME}_IGNORE_EXCEPTIONSandGREL_SHIELD_{NAME}_TIMEOUT_ERRORSraiseSettingsValidationError, at construction and on a reload. Rename the variable to_WHEN. - From 0.42.2, with env reads on,
GREL_IDEMPOTENT_REQUESTS_WAIT_TIMEOUTandGREL_IDEMPOTENT_REQUESTS_{NAME}_WAIT_TIMEOUTraiseSettingsValidationError, at construction and on a reload. Rename the variable to_MAX_WAIT. metrics_routerno longer imports fromgrelmicro.metrics. Import fromgrelmicro.integrations.fastapi.- The
document_*functions are removed. Register the component. CachedResponsesMiddleware(policies=...)is removed. Drop it.CacheErroris removed. Delete theexcept CacheError:block.lock=refuses"local",TrueandFalse, and"host"or"cluster"with no lock backend raisesOutOfContextError. Name the fold scope.wait_timeout=is renamedmax_wait=, andBulkheadConfig(max_wait=None)is refused. Passmax_wait=.IdempotencyandIdempotentRequestswith no lock backend raiseBackendScopeErrorinstagingandproduction. Register a lock backend.TaskLock.refresh()is renamedextend(), and a failed extension raisesLockExtendError. Follow theextend()rename.- A string passed to
key=raisesTypeError, andkey_maker=is gone. Passkey_template=orkey=. - A
TaskLockwith 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 customProviderwith the old names fails at class definition. Calllock_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,audienceis required, andJWKSVerifier,bans=andclient=are gone. Use the new factories. - A pattern holding a backend of its own goes through the backend check, and
IdempotentRequestsrefuses a memoryCacheinproduction. Register a shared backend or passrequires="process". CachedResponse()on a route that runs a check of its own fails install. DropCachedResponse()from that route.- From 0.42.1,
Grelmicro(uses=[p1, p2])raisesAmbiguousProviderErrorwhen the app opens, not at construction, andmicro.use(p1)thenmicro.use(p2)raises it too. A component of a kind a provider default holds raisesComponentAlreadyRegisteredErroronce 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 nowevery(interval=...), and cronmisfire_grace_secondsis nowmisfire_grace. The old names are gone. - Coordination:
lease_durationonLock,ReadWriteLock,LeaderElectionandTaskLock,renew_deadlineonLeaderElection, andmin_hold_durationonTaskLock. - Cache:
TTLCacheConfig.ttl,Cache.ttl,cached(ttl, stale_ttl), and thettlandstale_ttlofTTLCache.set,.get_or_setand.set_many. - HTTP:
IdempotencyConfig.ttl,CachedResponsesConfig.ttland its per-pathincludeTTLs, andCachedResponse(ttl). - Rate limiter: the sliding window
window. - Circuit breaker:
reset_timeoutonCircuitBreaker.consecutive_countandConsecutiveCountConfig. - Security:
ClientBanswindowandduration, andJWTVerifierttlandcache_ttl(0still turns the cache off). - Outbox:
lease_duration,keep_deliveredandpurge(older_than=...).keep_deliveredtakes no bool. Write0forFalse(delete on delivery) andNoneforTrue(keep for good).keep_delivered=1now means one second, where it used to becomeTrueand keep rows for good. From an environment variable, write0ornone. 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:
DuplicateFilterttl, andHealthCheckscache_ttl(0still 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
200of a not-found handler of your own. Declare a route for what has to be cached or replayed. - A
HEADis read from the storedGETonly on a route that answers both. require_precondition=andrequire_key=Trueanswer428and400only on a route that takes the request. An unknown URL gets404, and a method the route does not take gets405.- An
include=pattern never caches a route under middleware on a mount, a router or a mounted app. DeclareCachedResponse()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:
# 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:
# 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 |
# 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,/healthzand/metricsleave the OpenAPI schema. Passinclude_in_schema=True.- JSON logs use orjson when it is installed, which renders some values differently. Pin the serializer.
- An idempotent request carrying
AuthorizationorCookie, 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 aTTLCacheto share one. CachedResponses(vary_by_query=None)keys on the whole query string, whatever the environment says. Leave it unset to read the environment.IdempotencyMiddlewareanswers400,409,413and422asapplication/problem+json. A client readingdetailstill finds it. Nothing to change.- An
Idempotency-Keyholding a byte outside printable ASCII is answered400. 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
LockTimeoutErrorand answers503, not500.except TimeoutErrorstill catches it. Nothing to change. TimeoutraisesDeadlineExceededError, whichexcept TimeoutErrorstill catches. Nothing to change.TaskRouter(tasks=[...])andTasks(tasks=[...])copy the list they are given. Add tasks to the router, not the list.- A
Bulkhead'suses=answers beforemicro.fake()andmicro.override(...). Build the bulkhead with the test backends.
Changes that raise
GrelmicroMiddlewareimports fromgrelmicro, andIdempotencyMiddlewareandStoredResponsefromgrelmicro.http. Change the imports.- The
fastapiextra needs FastAPI 0.137 or later. Upgrade FastAPI. - A grelmicro decorator refuses a function a registrar such as
@tasks.everyalready holds. Put the registering decorator on top. IdempotentRequeststakesttl=,namespace=andcache=instead of anIdempotency, andkey_header=instead ofheader=. Passttl=andkey_header=.CachedResponses,ConditionalRequests,IdempotentRequests,RateLimitedRequestsandAccessLograiseSettingsValidationErrorfor a bad value, notTypeErrororValueError. CatchSettingsValidationError.CachedResponses(include=...)refuses a pattern for a path that answers noGET. Point the pattern at aGETroute.max_body_size=0, a bare string formethods=orrequire_precondition=, andbytesfor a header name are refused. Pass a positive size, a tuple and astr.AccessLogMiddlewareno longer carriesinclude,exclude,quiet,queryanduser_agentas attributes. Read them from theAccessLogcomponent.- A
Bulkheadrefuses two Components claiming one(kind, name)inuses=. List each kind once. - Every
Matchargument error is aValueErrorat construction. CatchValueError. - A shield's
timeout_errors=refuses aBaseException-only type, a set and a generator. Pass a tuple ofExceptionclasses. - A custom
CircuitBreakerStrategyhas to implementabandon(). Implementabandon(). - The Postgres circuit breaker recreates three stored functions on start. Keep
auto_migrateon or apply the change yourself. Lock.do_thread_*take the thread's identity. Uselock.from_thread.
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
AuthorizationorCookie - 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:
@retryand@fallbackreturned without engaging, silently.@measureand@instrumenttimed 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,@bulkheadand@shieldrefused the object at decoration, with aTypeErrorsaying they only decorate async functions.@cached(ttl=...)refused it too, with its own wording:supports async functions only.@cachedon aTTLCacheyou passed did not refuse, and raisedAttributeError: '...' object has no attribute '__qualname__'instead.CircuitBreakerraisedEventLoopDeadlockError, 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 aBaseException, so anexcept Exceptionaround 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
*SettingsValidationErrorclasses are gone, andFallback,Shield,TTLCache,cached(),TrustedProxiesandExternalConfigraiseSettingsValidationError. Catch the base error. Grelmicro(environment=...)refuses a value outside the four tiers. Name a real tier.- A bad
Matchargument orwhen=raisesValueError, notTypeError. CatchValueError. RedisProviderConfigErrorandPostgresProviderConfigErrorare 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.clientiplogger stops matching. Rename the logger. - With
GREL_ENV_LOADon,CircuitBreaker,RateLimiterand theShieldfactories read theirGREL_*variables at construction. A variable they ignored now applies. Check theGREL_*variables.
Changes that raise
grelmicro.clientipno longer imports. Import fromgrelmicro.security.- A named
CircuitBreaker,RateLimiterorShieldrefuses at startup a variable for an algorithm the code does not run. Remove the other algorithm's variable. CircuitBreakertakes no positional config,RateLimiterhas no bare constructor, and nine patterns loseconfig=. Usefrom_configor a factory.- A bad value raises
SettingsValidationErrorinstead ofpydantic.ValidationError. CatchSettingsValidationError.
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
RateLimiterRegistryandCircuitBreakerRegistryare renamed, andhealth_router(registry=...)takescomponent=. Rename toComponent.- 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
LogTimeZoneTypeis removed. UseTimeZoneName.- A timezone abbreviation such as
CESTis refused where the value is read. Name the zone. - Starting
Taskstwice raisesTaskStartOperationErrorinstead ofTaskAddOperationError. CatchTaskStartOperationError. - A negative
Tasks(shutdown_timeout=...)raisesTaskSettingsValidationErrorinstead of a plainValueError. It still subclassesValueError. CatchValueError.
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
- A passing
/healthzcheck leaves outerror, and a check with no details leaves outdetails. Read an absenterroras a pass. - Listing a Component no longer turns off every provider default. Drop the workaround.
REDIS_PASSWORDbeside aREDIS_URLis used, where it was dropped. SetREDIS_URLandREDIS_PASSWORD.- From 0.35.1, uvicorn's own lines follow your log format. Update the parser or turn it off.
- From 0.35.1, a
GREL_*variable set withoutGREL_ENV_LOADwarnsGrelmicroConfigWarning. It raises only under-W error. Fix the configuration.
Changes that raise
- A Redis or Valkey URL that carries credentials is refused beside a separate password. Keep the password in one place.
/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.runscounts every fire, so its total grows with the number of workers. Filter on the outcome.
Changes that raise
@cachedon a method without a key raisesTypeErrorat 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
urlandendpointfields read back asSecretUrl, and eachheadersvalue asSecretStr. 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
SQLiteLockAdapterandSQLiteScheduleAdaptertakeprovider=, not a path. Passprovider=.
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
- Credential fields read back as
SecretStr. Call.get_secret_value(). Metrics()with no endpoint exports nothing, where it sent tolocalhost:4318. Set an endpoint.
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.responseis now aresult()method. Useresult().
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.
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"]