Skip to content

feat: per-endpoint circuit breaker option#22

Merged
KhaledSalhab-Develeap merged 2 commits into
mainfrom
feat/per-endpoint-circuit-breaker
May 6, 2026
Merged

feat: per-endpoint circuit breaker option#22
KhaledSalhab-Develeap merged 2 commits into
mainfrom
feat/per-endpoint-circuit-breaker

Conversation

@KhaledSalhab-Develeap
Copy link
Copy Markdown
Collaborator

Summary

  • New opt-in option per_endpoint_circuit_breaker: bool = False on HyperpingClient and AsyncHyperpingClient. When enabled, each request path has its own CircuitBreaker instance, so one flaking endpoint no longer trips the breaker for traffic to healthy endpoints.
  • Default behaviour is unchanged: a single shared breaker still governs every request. All existing breaker tests pass without modification.
  • Per-path breaker state is exposed via client.circuit_breaker_state_for(path). Query string and fragment are stripped from the key so /v3/incidents and /v3/incidents?status=open share state. The existing client.circuit_breaker property continues to return the shared breaker.

Why

The previous single-shared breaker design had a known failure mode (M3 in BACKLOG.md): if any one endpoint started returning 5xx repeatedly, every other endpoint was also blocked once the failure threshold was reached. Reads on healthy endpoints were punished alongside the flaking one. This change lets users opt into per-path isolation when that trade-off doesn't fit their workload.

Implementation notes

  • dict[str, CircuitBreaker] keyed by path, lazily populated on first request.
  • The dict is protected by a threading.Lock; each CircuitBreaker keeps its own internal lock for state mutations.
  • The same circuit_breaker_config is reused for every per-path breaker (no per-path tuning, by design — keeps the API simple).
  • Configuration table updated in README.md; CHANGELOG.md [Unreleased] section added; BACKLOG.md M3 marked done.

Test plan

  • tests/unit/test_per_endpoint_circuit_breaker.py — 7 new tests: isolation, per-path state query, query-string stripping, default behaviour unchanged, error on misuse, async parity, 50-thread concurrent access
  • Full suite: 422 tests pass
  • Coverage: 96% overall; client.py 100%, _async_client.py 91%, _circuit_breaker.py 94%
  • ruff check, ruff format, mypy src all clean

Add `per_endpoint_circuit_breaker: bool = False` to `HyperpingClient` and
`AsyncHyperpingClient`. When enabled, the client maintains an independent
`CircuitBreaker` per request path (query string and fragment stripped from
the key) so a single flaky endpoint no longer blocks traffic to healthy
ones. Default `False` preserves the original single-shared-breaker
behaviour and all existing breaker tests.

Per-path state is queryable via `client.circuit_breaker_state_for(path)`;
the existing `client.circuit_breaker` property still returns the shared
breaker. The per-path dict is protected by its own `threading.Lock`; the
per-path `CircuitBreaker` instances retain their own internal lock.

Tests cover: isolation between paths, state-query API, query-string
stripping, default-mode unchanged, async parity, and 50-thread concurrent
access.
Address review feedback on the initial per-endpoint breaker implementation.

The previous version keyed the breaker dict on the literal request path. For
paths that embed resource UUIDs (`/v1/monitors/{uuid}`, etc.) this meant
every resource ended up with its own breaker, the dict could grow unbounded
in long-lived clients, and the README's `circuit_breaker_state_for(Endpoint.MONITORS)`
example only saw list-call state, not per-resource calls.

Changes:

- Default breaker key now collapses sub-resource paths under their matching
  `Endpoint` prefix (`/v1/monitors`, `/v3/incidents`, ...). The breaker set is
  bounded by the number of Endpoint values, not by resource cardinality.
- New `breaker_key_fn: Callable[[str], str] | None` lets callers opt into a
  different scheme (per-UUID, per-verb, etc.) and explicitly own bounding.
- OPEN-state error message in per-endpoint mode now includes the breaker key
  ("Circuit breaker OPEN for '/v1/monitors' - ..."), so the failure points at
  which endpoint tripped.
- `circuit_breaker_state_for(path)` now falls back to the shared breaker's
  state in default mode instead of raising, so the call is always safe to
  make and toggling the flag at construction time doesn't change the API
  surface callers see.
- `_breaker_key` rewritten with `urllib.parse.urlsplit` and the dict-lock
  comment explains the `threading.Lock` choice for the async client.

Tests added for endpoint canonicalisation, custom `breaker_key_fn`, default-
mode `circuit_breaker_state_for` returning shared state, and the OPEN error
message including the endpoint key.
@KhaledSalhab-Develeap KhaledSalhab-Develeap merged commit 976fc49 into main May 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant