Skip to content

CircuitBreaker.force_open() / force_closed() — manual control #118

Closed as not planned

Description

@lesnik512

Repo: modern-python/httpware
Suggested labels: enhancement, needs-info

What

src/httpware/middleware/resilience/circuit_breaker.py has no manual override. Polly calls this
ManualControl: a way for an operator to pin the breaker open (shed all load to a backend known to
be sick) or pin it closed (ignore the breaker during a controlled test).

State of the surrounding work

The rest of the control surface is shipped and this is the only piece left. 0.13.0 added the opt-in
time-based failure-rate mode; 0.14.0 added the read-only state property and the public
CircuitState enum. The original decision was that the control surface would be events-only — no
state, no reset(), no isolate(). Half of that has since been reversed by the read-only
state property, deliberately: reading state is an observability affordance and cannot desync
anything. Writing state is the half that was not reversed.

Why it is deferred rather than declined

It is the genuinely YAGNI half for an HTTP client, where the operator who wants to stop sending
requests can simply stop sending requests — unlike a server-side breaker, the caller owns the
call site. But it is not wrong in principle, so it is demand-gated rather than rejected. If it is
ever declined outright rather than built, that belongs in docs/adr/ as a rejected alternative,
not here.

Do not regress

Whatever shape this takes, preserve httpware's HTTP-native failure classification: 429 and the rest
of 4xx count as circuit-breaker successes by default. That is
docs/adr/0004-4xx-does-not-trip-the-circuit-breaker.md, and it is what puts this breaker ahead of
the generic predicate-over-"did it fail" breakers it is measured against. A manual-control API must
not become a back door that reintroduces "any failure trips it".

Revisit trigger

A concrete user report naming an operational situation that failure_status_codes, the read-only
state property, and not sending the request cannot together address.

Activity

  1. lesnik512 commented on Sep 19, 2026

    @lesnik512
    MemberAuthor

    Closing as declined rather than deferred.

    The per-instance primitive does not solve the operational problem it is named for. A service runs as several replicas, each with its own breaker state. force_open() on one replica pins one replica; an admin endpoint that calls it reaches whichever pod the load balancer picked, and a pod that starts afterwards comes up unpinned. The in-process libraries this was modelled on have the same limitation and do not solve it:

    • Polly's CircuitBreakerManualControl is an in-process object; the docs say nothing about replicas.
    • Resilience4j has FORCED_OPEN / DISABLED / METRICS_ONLY and a Spring Boot actuator write operation (/actuator/circuitbreakers/{name} with updateState), which changes one JVM.

    The two approaches that do work fleet-wide are out of scope for httpware:

    • Config-driven pins (Hystrix circuitBreaker.forceOpen / forceClosed as dynamic Archaius properties read on every admit). The library defines a knob and a central config system delivers it. For an HTTP client this collapses into what callers can already do: read the same flag before sending and not send. A breaker method adds a reconcile loop on every replica and nothing the flag check lacks.
    • Shared state in an external store (pybreaker CircuitRedisStorage, stoplight lock(RED) on a Redis data store). That puts a network round trip inside every admit decision and adds a failure mode when the store is down. _CircuitBreakerState is documented as performing no I/O inside a transition, and that is the right contract for a thin wrapper.

    What stays. failure_status_codes, the read-only state property, and the events remain the control surface. The HTTP-native classification in docs/adr/0003-4xx-does-not-trip-the-circuit-breaker.md is untouched.

    Per this issue's own note, the decline is recorded in docs/adr/0004-circuit-breaker-scope.md (PR #128).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    wontfixThis will not be worked on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions