Payments are experiencing issues due to temporary restrictions in Russia. If your payment does not go through, please submit a support request.Our support team is available 24/7 — we are always here to help with hosting and server issues.We are now accepting requests for dedicated server rental and colocation services in our data center.Reminder: we recommend enabling backups for additional data protection.A new VPS/VDS lineup with NVMe storage and improved performance is now available.Maintenance work on some servers has been completed. All services are operating normally.
Article10 min readViews0

External API Returns Errors: Testing Circuit Breaker Without Cascade Timeouts

Practical guide to circuit breaker: three technical failures trip the breaker, requests stop reaching the external API, a failed probe extends the pause, and a successful one restores normal operation.

Comments 0

A specialist closes the protective gateway before the line, passing a single test capsule
In this article

If an external API repeatedly returns technical errors, continuing to send every request until timeout is dangerous: the queue grows, worker processes are occupied waiting, and the dependent service receives even more load. A circuit breaker—a switch for calls—temporarily stops requests after a set number of failures, then allows a limited probe and returns to normal mode only after success.

Below is a safe model without network or production data. It verifies not only that the circuit opens, but also three critical edge cases: a business error does not count as unavailability, a request during the pause does not reach the dependency, and a failed probe restarts the pause.

Three states and one measurable outcome

State

What the client does

What to check

closed

Forwards calls to an external API and counts technical failures

The counter resets after a successful response

open

Immediately rejects calls without contacting the dependency

The number of external API calls does not increase

half-open

After a pause, allows a limited number of test calls

Success closes the circuit; a technical failure reopens it

This model matches the description of the Circuit Breaker pattern in Microsoft Learn: an open state does not heal the external system but gives it time to recover and protects the caller from a cascade of waits.

Which errors count as a dependency failure

  • Connection or read timeout when the responsible external node is confirmed.

  • Connection break and documented server responses indicating temporary unavailability.

  • Connection exhaustion or another local cause — only after diagnostics: such a failure may originate within the client itself.

  • Business failures such as "unknown SKU" or "insufficient permissions" do not open the circuit: the service responded, and repeating the same request without changing the data will not resolve the cause.

It is premature to draw a single conclusion from one response code. For example, HTTP 429 may indicate a rate limit: the pause is dictated by the provider's policy and sometimes by the Retry-After header. HTTP 500 can be either a brief glitch or a persistent error specific to a particular request. The error classifier must be a separate, verifiable integration rule.

Test environment and initial conditions

  • CPython 3.12.14, standard library only; check performed on October 27, 2026.

  • Isolated model with no network, files, database, or background processes.

  • Threshold: three consecutive technical failures; pause before retry: 10 simulated seconds.

  • Time is controlled by FakeClock, so the test does not wait for real 10 seconds and reproduces identically.

  • The mock API returns a predefined sequence: business failure, four technical failures, two successful responses.

Save the example in a new temporary directory as circuit_breaker_lab.py and run it as a regular user. It writes nothing and deletes nothing outside the example file itself.

from collections import deque from dataclasses import dataclass from typing import Callable class DependencyUnavailable(RuntimeError): pass class BusinessRejected(RuntimeError): pass class CircuitOpen(RuntimeError): pass @dataclass class FakeClock: value: float = 0.0 def now(self) -> float: return self.value def advance(self, seconds: float) -> None: if seconds < 0: raise ValueError("seconds must be non-negative") self.value += seconds class CircuitBreaker: def __init__( self, failure_threshold: int, reset_timeout: float, clock: Callable[[], float], ) -> None: if failure_threshold < 1 or reset_timeout <= 0: raise ValueError("invalid circuit breaker settings") self.failure_threshold = failure_threshold self.reset_timeout = reset_timeout self.clock = clock self.state = "closed" self.failures = 0 self.opened_at: float | None = None def _open(self) -> None: self.state = "open" self.opened_at = self.clock() def call(self, operation: Callable[[], str]) -> str: if self.state == "open": assert self.opened_at is not None if self.clock() - self.opened_at < self.reset_timeout: raise CircuitOpen("dependency call is temporarily blocked") self.state = "half-open" try: result = operation() except DependencyUnavailable: if self.state == "half-open": self._open() else: self.failures += 1 if self.failures >= self.failure_threshold: self._open() raise except BusinessRejected: # The dependency answered: the request itself was rejected. raise else: self.state = "closed" self.failures = 0 self.opened_at = None return result class FakeSupplierApi: def __init__(self, outcomes: list[str]) -> None: self.outcomes = deque(outcomes) self.calls = 0 def update_stock(self) -> str: self.calls += 1 outcome = self.outcomes.popleft() if outcome == "unavailable": raise DependencyUnavailable("supplier API timed out") if outcome == "business_rejected": raise BusinessRejected("unknown SKU") return outcome clock = FakeClock() api = FakeSupplierApi([ "business_rejected", "unavailable", "unavailable", "unavailable", "unavailable", "stock-updated", "stock-updated-again", ]) breaker = CircuitBreaker(failure_threshold=3, reset_timeout=10, clock=clock.now) try: breaker.call(api.update_stock) except BusinessRejected: pass assert breaker.state == "closed" and breaker.failures == 0 print(f"business error: state={breaker.state} failures={breaker.failures} calls={api.calls}") for _ in range(3): try: breaker.call(api.update_stock) except DependencyUnavailable: pass assert breaker.state == "open" and api.calls == 4 print(f"threshold: state={breaker.state} failures={breaker.failures} calls={api.calls}") try: breaker.call(api.update_stock) except CircuitOpen: pass assert api.calls == 4 print(f"blocked: state={breaker.state} dependency_calls={api.calls}") clock.advance(10) try: breaker.call(api.update_stock) except DependencyUnavailable: pass assert breaker.state == "open" and api.calls == 5 print(f"failed probe: state={breaker.state} dependency_calls={api.calls}") try: breaker.call(api.update_stock) except CircuitOpen: pass assert api.calls == 5 clock.advance(10) assert breaker.call(api.update_stock) == "stock-updated" assert breaker.state == "closed" and breaker.failures == 0 and api.calls == 6 print(f"successful probe: state={breaker.state} dependency_calls={api.calls}") assert breaker.call(api.update_stock) == "stock-updated-again" assert api.calls == 7 print(f"normal call: state={breaker.state} dependency_calls={api.calls}")

Expected and actual output:

business error: state=closed failures=0 calls=1 threshold: state=open failures=3 calls=4 blocked: state=open dependency_calls=4 failed probe: state=open dependency_calls=5 successful probe: state=closed dependency_calls=6 normal call: state=closed dependency_calls=7

The process completed with exit code 0. All transitions are additionally secured with assert checks: if the call still reaches an external dependency with an open circuit or the counter fails to reset after success, the example will terminate with an error.

What exactly the run confirmed

Scenario

Observation

Result criteria

Business failure

State closed, 0 technical failures

A request failure does not worsen the dependency state

Three technical failures

State open after the fourth total call

The threshold opens the circuit

Call before the pause expires

The dependency counter remains 4

Request blocked locally

Failed attempt

State is open again, 5 dependency calls

Pause restarts

Successful attempt

State is closed, 6 dependency calls

Normal mode restored

Next request

Success, 7 dependency calls

After recovery, traffic resumes

Why monotonic clocks are needed for pauses

In production code, pause duration should be measured using monotonic clocks: they do not run backward when system time is adjusted. In Python, the time.monotonic function is designed for this purpose. Use wall-clock time only for logs and event timestamps.

The educational FakeClock implements the same critical property: its value changes only via an explicit advance. This allows the moment before the pause boundary and the boundary itself to be tested separately, without turning the test into a ten-second wait.

Circuit breaker does not replace retries

Retry answers the question "when to try the same call again." The circuit breaker answers a different question: "should we even contact the dependency right now?" If both mechanisms are combined without a shared budget, a single user request can trigger multiple attempts before the circuit opens, and the queue can generate many identical waves of load.

If the provider returns 429 or 503 with a Retry-After header, first check the header format and the calculated pause. The local reset_timeout must not cause the client to attempt a request before the explicitly indicated valid moment.

What to add before the production launch

  1. Agree on a list of errors to consider for each external method, not for the API as a whole.

  2. Set thresholds and pauses based on observed recovery time, provider limits, and acceptable business latency.

  3. Limit the number of half-open probes during contention. In multiple processes, use shared atomic coordination or a separate chain per process with clear consequences.

  4. Separate chains by independent dependencies and operations: a failure in inventory updates should not stop order status reads.

  5. Add metrics for closed/open/half-open transitions, the count of locally rejected calls, and probe results.

  6. Determine the calling process's behavior: queue the task, return a controlled error, or use acceptable stale data.

  7. Verify recovery after application restart and state synchronization between instances.

For write operations, resolve idempotency separately. If the client receives a timeout, the external service might have applied the change even though the response did not arrive. Opening the circuit prevents new calls, but it does not determine the fate of an already sent command.

Model boundaries

  • One process without threads or async tasks was tested; concurrent access to the half-open state was not modeled.

  • Real network libraries, DNS, TLS, proxies, connection limits, and the specific provider's response format were not tested.

  • A threshold of 3 and a 10-second pause were chosen for a tutorial run, not as universal production values.

  • The example does not persist state between restarts and does not coordinate multiple application instances.

  • The model does not retry requests or modify data: it only checks whether the calling process is allowed to access the dependency.

The readiness criterion here is simple: during a persistent technical failure, the number of actual calls stops growing, requests are rejected locally until the pause ends, a failed attempt does not release the flow, and a successful one returns to normal mode. Everything else—threshold values, a shared or local counter, and business reaction—must be fixed in the contract for the specific integration.

Discussion 0

Share your experience and ask questions. Comments without links appear after editorial review.

No comments yet. Start the discussion.