Changelog¶
All notable changes to this project will be documented in this file.
[0.9.4] - 2026.06.03¶
Added¶
PrometheusListener(name, registry=...). Pass a dedicatedCollectorRegistryto redirect fluxgate's metrics off the default global one. ResolvesDuplicated timeseriesunderimportlib.reload/uvicorn --reload, or when another component owns the same metric name.registry=None(default) keeps the previous behaviour.PrometheusListener.close(). Drops every labelset this listener registered for itscircuit_name. Idempotent. For transient breakers (per-tenant / per-test) whose timeseries would otherwise leak.
Fixed¶
consecutive_failuresresets when the breaker entersHALF_OPEN(sync and async).FailureStreak(N)previously carried the counter atNinto HALF_OPEN, so the first probe failure re-tripped the breaker immediately instead of being treated as one probe.- Async
_HalfOpen.executereleases its semaphore slot before dispatching. When state changed before the slot was acquired, dispatch ran inside the slot — a slow CLOSED probe dispatched out of HALF_OPEN throttled every subsequent admission, turningmax_half_open_calls=Ninto "N minus in-flight dispatches". The slot now only covers the probe path. _SlackBase.__init__no longer accepts a deadtokenargument. The base stored it asself._tokenbut never used the value; each subclass already holds the token in its httpx client'sAuthorizationheader. Signature honesty cleanup — not a security fix, since httpx masksAuthorizationin__repr__.LogListenerdefault logger changed from the root logger tologging.getLogger("fluxgate.listeners.log"), sologging.getLogger("fluxgate").setLevel(...)scopes fluxgate output. Alogger=kwarg still overrides.
[0.9.3] - 2026.06.02¶
Fixed¶
- Tripper now evaluated on the success path in
CLOSED._Closed.executepreviously evaluated the tripper only inside itsexceptbranch, so trippers that monitor metrics accumulating on successful calls (SlowRate,AvgLatency) never tripped a "successful but slow" workload even when the threshold was clearly exceeded. Evaluation now fires after every record, success or failure, in both the sync and async breakers; the async variant preserves the existing lock+signal+notify pattern. The two branches were collapsed into one by capturing the call outcome as atuple[R, float] | Exceptionunion and dispatching onisinstance. - Synchronous
CircuitBreakerno longer silently drops async listeners.ListenerandAsyncListenerareruntime_checkableProtocols differentiated only by the syntactic async-ness of__call__, whichruntime_checkabledoes not inspect — so a coroutine-returning callable was accepted by a sync breaker, invoked withoutawait, and the notification was discarded with aRuntimeWarninginvisible in most production logging setups. The sync_notifynow mirrors the existing guard inAsyncCircuitBreaker._notify: a returned coroutine is closed cleanly and an explicitERROR-level log is emitted, while other listeners in the same notification continue to fire.
Changed¶
- Default permit changed to
RampUp(0.1, 1.0, 60.0)(previouslyRampUp(0.0, 1.0, 60.0)). Withinitial=0.0the computed ratio at HALF_OPEN entry was 0, sorandom() < 0denied every probe until the ramp progressed — effectively blocking traffic for ~60s after every cooldown, on top of theCooldownitself. The new default admits ~10% of probes immediately.
Breaking Changes¶
RampUp(initial=0.0, ...)now raisesValueError. The value never made sense for HALF_OPEN admission (see above) and any breaker still constructing one was silently locking itself out. Migration: pass any small positiveinitialsuch as0.1, matching the new default.
[0.9.2] - 2026.06.02¶
Changed¶
AsyncCircuitBreakerconsolidated to a single_lock. The two locks introduced in 0.9.0 (_state_lock+_window_lock) merged into one, and the nested locking pattern is gone. Critical sections now hold synchronous code only. Public API and observable behaviour are unchanged.- Listener notifications moved outside the lock.
_transition_toreturns aSignalfor the caller to notify after releasing_lock, so a slow listener can no longer stall other calls or deadlock by re-entering the breaker. - State freshness guard switched from
from_statestrings to handler identity. The three_try_transition_to_*helpers are gone; each handler now checkscb._state is not selfunder_lockto discard stale outcomes. Equivalent protection against the normal trip race. A regression back to the same handler instance (e.g.closed → open → half_open → closed) may still let one stale sample land in the freshly reset window — pair small windows withMinRequestsif that could flip your tripper. _transition_tois now synchronous and returns aSignal. Callers hold_lock, transition, release, then_notify. Locking responsibility stays at the call site.- Explicit-command entry points unified into
_command.reset/disable/metrics_only/force_opendelegate to a single helper instead of each duplicating thelock → transition → notifypattern. _HalfOpenre-entry check usesis selfinstead ofself.cb._state.state != "half_open"— object identity over string comparison.- New concurrency tests cover the trip race, slow listener, in-flight
disable(), and stale-outcome containment.
[0.9.1] - 2026.06.01¶
Changed¶
Metricis now a monoid;_Aggregatorremoved. The cumulative-counter dataclass introduced in 0.9.0 is gone —Metricgainedempty(),from_record(),__add__, and__sub__, andCountWindow/TimeWindowkeep a runningMetricupdated via these operators on everyrecord(). The two-class indirection (mutable_Aggregatormirror of immutableMetric) collapses into one. PublicWindow/MetricAPI is unchanged.TimeWindow.__init__delegates toreset(), andCountWindow._max_sizeis replaced byself._records.maxlen— both eliminate state-init duplication.- Test fixture for empty
MetricusesMetric.empty()instead of a hand-rolledMetric(...)literal, so adding a field toMetricwon't silently bypass empty-metric coverage intest_trippers_with_empty_metrics.
[0.9.0] - 2026.05.31¶
Changed¶
- Slack listeners share a private base class (
_SlackBase).SlackListenerandAsyncSlackListenerno longer duplicate template lookup, payload construction, and thread-tracking logic line-by-line; only the HTTP client and__call__differ. As a side effect, both classes now read defaults from the shared base, so overridingTRANSITION_TEMPLATESonSlackListenerno longer leaks intoAsyncSlackListener(which was an accident of the previous direct reference). - Window cumulative counters consolidated into
_Aggregator. The five-counter accumulation pattern (total_count,total_failure_count,total_duration,slow_counts) previously lived in three places —CountWindow._admit,TimeWindow._admit, andTimeWindow.Bucket.admit. They now live in one dataclass withadd/remove/subtract/reset/to_metricmethods. Adding a new aggregate metric (e.g. p99 latency) is now a single-site change. Public window API is unchanged. - State handlers reused via a
_handlersdict. Each breaker now builds one instance perStatevalue at construction time and looks upself._handlers[state]in_transition_to. The six-branchif/elif/elseladder is gone — previously the final case (forced_open) sat in an unguardedelse, silently catching anything it didn't recognise; the new dict lookup raisesKeyErrorinstead, so adding aStatevalue without registering its handler fails loudly. Handlers carry only a reference to the parent breaker, so reusing one instance per state across transitions is safe. - State
executemethods use_record_success/_record_failurehelpers. TheRecord(success=..., duration=..., slow_at=_classify_slow(...))+ counter-update boilerplate previously appeared six times across_Closed,_HalfOpen,_MetricsOnly(sync and async). Centralising it makes what each state actually decides — tripper checks, transitions, no-op — visible at a glance. In the async breaker the helpers require the caller to hold_window_lock, keeping locking responsibility at the call site. ListenerandAsyncListenerare nowruntime_checkableProtocols (previously ABCs). A listener can now be any callable matching the protocol — plain functions, lambdas, or bound methods all satisfyListener/AsyncListenerwithout inheriting. Existingclass MyListener(Listener): ...patterns continue to work via structural typing.
Breaking Changes¶
StateEnumenum replaced withStateLiteral alias (fluxgate.State). State values were always just six fixed strings with no behaviour attached — a Literal expresses that more honestly and removes the constant.valuenoise. AffectsCircuitBreakerInfo.state,Signal.old_state/new_state,CallContext.state, and any user code that compares state values. Migration: replaceStateEnum.OPENwith"open",StateEnum.CLOSEDwith"closed", etc. (full set:"closed","open","half_open","metrics_only","disabled","forced_open"). To iterate all valid states, usetyping.get_args(State)instead of iterating the enum.CallNotPermittedError.messageattribute removed. The message was stored twice (inargs[0]viasuper().__init__and again asself.message). Usestr(err)orerr.args[0]to read the message — the standard Python convention since PEP 352.Tripper.__call__signature changed to a singleCallContextargument. The previous(metric, state, consecutive_failures)triple is replaced by(ctx: CallContext), whereCallContextis a frozen dataclass exposing.metric,.state, and.consecutive_failures. OnlyFailureStreakactually usedconsecutive_failures, so the previous signature forced every other tripper to declare an unused parameter. Future additions to the breaker's per-call state can be added toCallContextwithout changing the signature again.
Migration (Tripper.__call__):
# Before (v0.8.x)
from fluxgate.trippers import Tripper
from fluxgate.metric import Metric
from fluxgate.state import StateEnum
class MyTripper(Tripper):
def __call__(
self, metric: Metric, state: StateEnum, consecutive_failures: int
) -> bool:
return metric.failure_count > 10
# After (v0.9.0)
from fluxgate.trippers import CallContext, Tripper
class MyTripper(Tripper):
def __call__(self, ctx: CallContext) -> bool:
return ctx.metric.failure_count > 10
[0.8.0] - 2026.05.28¶
Breaking Changes¶
nameparameter removed fromCircuitBreakerandAsyncCircuitBreaker. The circuit breaker is now anonymous — its identity is determined by where it's attached, not by a string. Identification for monitoring is the listener's responsibility.Signal.circuit_namefield removed.Signalnow carries onlyold_state,new_state, andtimestamp.CircuitBreakerInfo.namefield removed. Use the variable name or surrounding context to identify the breaker when inspectingcb.info().- Listeners require a
nameparameter:LogListener,PrometheusListener,SlackListener, andAsyncSlackListenerall takenameas their first argument. The name is used as a log prefix, a Prometheus label, or the identifier shown in Slack messages. SlackListener._open_threadssimplified: a listener now tracks a single open thread instead of a per-circuit dictionary, since one instance is intended for one circuit.
Migration:
# Before (v0.7.x)
cb = CircuitBreaker(
name="payment_api",
tripper=MinRequests(20) & FailureRate(0.5),
listeners=[
LogListener(),
PrometheusListener(),
SlackListener(channel="C123", token="xoxb-..."),
],
)
# After (v0.8.0)
cb = CircuitBreaker(
tripper=MinRequests(20) & FailureRate(0.5),
listeners=[
LogListener(name="payment_api"),
PrometheusListener(name="payment_api"),
SlackListener(name="payment_api", channel="C123", token="xoxb-..."),
],
)
To attach a single PrometheusListener to many circuits as before, create one instance per circuit — the module-level Gauge/Counter still aggregates via the circuit_name label.
Custom listeners that previously read signal.circuit_name should accept name in their __init__ and use self._name.
[0.7.0] - 2026.05.28¶
Breaking Changes¶
SlowRatenow owns its threshold:SlowRate(ratio)is replaced bySlowRate(ratio, threshold). The "slow" duration is no longer a global property of the breaker; eachSlowRateinstance declares the threshold it cares about, so several can coexist with different thresholds.slow_thresholdparameter removed fromCircuitBreakerandAsyncCircuitBreaker. Move the value into eachSlowRateinstance.- Default tripper no longer includes
SlowRate: The default becameMinRequests(100) & FailureRate(0.5). Previously the default tripper includedSlowRate(1.0)paired withslow_threshold=60.0, which was effectively disabled (it required 100% of calls to exceed 60s). If you want slow-call detection, addSlowRate(ratio, threshold=...)explicitly. Metric.slow_count: intreplaced byslow_counts: Mapping[float, int](per-threshold counters). The previousMetric.slow_rateproperty is now the methodMetric.slow_rate(threshold).Record.is_slowremoved;Record.slow_at: tuple[float, ...]added: each record carries the set of thresholds it exceeded (computed by the producer, typically the circuit breaker on the call hot path). Windows simply aggregate counters keyed by these values.- Slow-call classification now uses
>=(a duration equal to the threshold counts as slow). The previousslow_thresholdparameter used>. - Component interfaces are now abstract base classes (
abc.ABC):fluxgate.interfacesis removed.IWindow,ITracker,ITripper,IRetry,IPermit,IListener,IAsyncListenerProtocols are replaced by ABCsWindow,Tracker,Tripper,Retry,Permit,Listener,AsyncListenerdefined in their respective modules (fluxgate.windows,fluxgate.trackers, ...,fluxgate.listeners). TheTripperBase/TrackerBase/RetryBasehelpers are gone; their names became the base classes themselves. Instantiating an ABC directly raisesTypeErrorimmediately, so misuse (CircuitBreaker(retry=Retry())) fails fast at construction time instead of on the first call. Tripperis iterable: composite trippers (_And/_Or) yield from their children, leaves yield themselves. The circuit breaker uses this to discoverSlowRatethresholds inside the tripper tree.
Migration:
# Before (v0.6.x)
from fluxgate.interfaces import IListener
class MyListener(IListener):
def __call__(self, signal): ...
cb = CircuitBreaker(
name="api",
tripper=MinRequests(10) & (FailureRate(0.5) | SlowRate(0.3)),
slow_threshold=1.0,
)
# After (v0.7.0)
from fluxgate.listeners import Listener
class MyListener(Listener):
def __call__(self, signal): ...
cb = CircuitBreaker(
name="api",
tripper=MinRequests(10) & (FailureRate(0.5) | SlowRate(0.3, threshold=1.0)),
)
Custom Window implementations need no new methods. To support SlowRate, aggregate record.slow_at into per-threshold counters in your record() and surface them on Metric.slow_counts.
[0.6.1] - 2026.05.27¶
Fixed¶
AsyncCircuitBreaker._notify: Async listeners implemented as callable classes (withasync def __call__) are now properly awaited. Previously,inspect.iscoroutinefunctionreturnedFalsefor class instances, causing the returned coroutine to be silently dropped. (#1)
[0.6.0] - 2025.12.18¶
Breaking Changes¶
- Removed
notifyparameter from manual control methods: Thenotifyparameter has been removed fromreset(),disable(),metrics_only(), andforce_open()methods. Listeners are now always notified on state transitions.
[0.5.1] - 2025.12.17¶
Fixed¶
- SlackListener thread management: Improved thread lifecycle for manual state transitions.
- Thread now ends on transitions to
CLOSED,DISABLED, orMETRICS_ONLY(previously onlyHALF_OPEN → CLOSED) - Thread continues on transitions to
FORCED_OPENsince the failure cycle persists - Direct
reset()fromOPENnow properly clears the thread for the next failure cycle
- Thread now ends on transitions to
[0.5.0] - 2025.12.16¶
Added¶
Allpermit strategy: A simple permit that always allows all calls inHALF_OPENstate. Useful for testing or when you want to rely solely on the tripper for state transitions.
from fluxgate import CircuitBreaker
from fluxgate.permits import All
cb = CircuitBreaker(name="api", permit=All())
TemplateTypedDict for SlackListener: Customize Slack message templates by subclassingSlackListenerand overridingTRANSITION_TEMPLATESandFALLBACK_TEMPLATEclass attributes.
from fluxgate.listeners.slack import SlackListener, Template
from fluxgate.state import StateEnum
class CustomSlackListener(SlackListener):
TRANSITION_TEMPLATES: dict[tuple[StateEnum, StateEnum], Template] = {
(StateEnum.CLOSED, StateEnum.OPEN): {
"title": "🚨 Alert",
"color": "#FF0000",
"description": "Circuit opened!",
},
}
[0.4.1] - 2025.12.15¶
Added¶
- Sensible defaults for CircuitBreaker: All component parameters now have default values, allowing simpler initialization with just a name:
from fluxgate import CircuitBreaker
cb = CircuitBreaker("my-service")
@cb
def call_api():
return requests.get("https://api.example.com")
Default values:
window:CountWindow(100)tracker:All()tripper:MinRequests(100) & (FailureRate(0.5) | SlowRate(1.0))retry:Cooldown(60.0)permit:RampUp(0.0, 1.0, 60.0)slow_threshold:60.0
[0.4.0] - 2025.12.05¶
Breaking Changes¶
AvgLatencynow uses>=instead of>: The tripper now trips when the average latency reaches or exceeds the threshold, consistent with other rate-based trippers (FailureRate,SlowRate).TypeOfnow requires at least one exception type: CreatingTypeOf()without arguments now raisesValueError.
Fixed¶
SlackListenerno longer crashes on unsupported transitions: Previously, state transitions not in the predefined message templates (e.g.,DISABLED,FORCED_OPEN,METRICS_ONLY, or manualreset()fromOPENtoCLOSED) would raiseKeyError. Now these transitions are silently ignored.
[0.3.1] - 2025.12.05¶
Breaking Changes¶
ITripper.consecutive_failuresis now required: Theconsecutive_failuresparameter no longer has a default value. Custom tripper implementations must pass this argument explicitly.
[0.3.0] - 2025.12.05¶
Breaking Changes¶
ITripperinterface signature changed: The__call__method now accepts aconsecutive_failuresparameter. Custom tripper implementations must update their signature:
# Before (v0.2.x)
def __call__(self, metric: Metric, state: StateEnum) -> bool: ...
# After (v0.3.0)
def __call__(self, metric: Metric, state: StateEnum, consecutive_failures: int = 0) -> bool: ...
Added¶
FailureStreaktripper: Trip the circuit after N consecutive failures. Useful for fast failure detection during cold start or complete service outage.
from fluxgate.trippers import FailureStreak, MinRequests, FailureRate
# Fast trip on 5 consecutive failures, or statistical trip on 50% failure rate
tripper = FailureStreak(5) | (MinRequests(20) & FailureRate(0.5))
[0.2.0] - 2025.12.03¶
Breaking Changes¶
slow_thresholdis now required: Theslow_thresholdparameter no longer has a default value and must be explicitly set when creatingCircuitBreakerorAsyncCircuitBreakerinstances.- If you don't use
SlowRate, set it tofloat("inf")to disable slow call tracking. - This follows Python's principle: "Explicit is better than implicit."
- If you don't use
Migration:
# Before (v0.1.x)
cb = CircuitBreaker(
name="api",
window=CountWindow(size=100),
...
)
# After (v0.2.0)
cb = CircuitBreaker(
name="api",
window=CountWindow(size=100),
...
slow_threshold=float("inf"), # or a specific value like 3.0
)
[0.1.2] - 2025.12.03¶
Changed¶
- LogListener: Added
loggerandlevel_mapparameters for flexible logging configuration.logger: Inject a custom logger instance instead of using the root logger.level_map: Customize log levels per state (default:OPEN/FORCED_OPEN→WARNING, others →INFO).
[0.1.1] - 2025.12.01¶
This is the initial public release of Fluxgate.
Features¶
- ✨ Core: Initial implementation of
CircuitBreakerandAsyncCircuitBreaker. - ✨ Windows: Sliding window strategies (
CountWindow,TimeWindow). - ✨ Trackers: Composable failure trackers (
All,TypeOf,Custom) with&,|, and~operators. - ✨ Trippers: Composable tripping conditions (
Closed,HalfOpened,MinRequests,FailureRate,AvgLatency,SlowRate) with&and|operators. - ✨ Retries: Recovery strategies (
Never,Always,Cooldown,Backoff). - ✨ Permits: Gradual recovery strategies (
Random,RampUp). - ✨ Listeners: Built-in monitoring and alerting integrations (
LogListener,PrometheusListener,SlackListener). - ✨ Manual Control: Methods for manual intervention (
disable,metrics_only,force_open,reset). - ✨ Typing: Full type hinting and
py.typedcompliance for excellent IDE support.