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.

In this article
Если внешний API подряд отвечает техническими ошибками, продолжать каждый запрос до тайм-аута опасно: очередь растёт, рабочие процессы заняты ожиданием, а зависимый сервис получает ещё больше нагрузки. Circuit breaker — «автоматический выключатель» для вызовов — после заданного числа сбоев временно прекращает обращения, затем пропускает ограниченную пробу и возвращает обычный режим только после успеха.
Ниже — безопасная модель без сети и рабочих данных. Она проверяет не только открытие цепи, но и три важных края: бизнес-ошибка не считается недоступностью, запрос во время паузы не достигает зависимости, а неудачная проба начинает паузу заново.
Три состояния и один измеримый результат
Состояние | Что делает клиент | Что проверить |
|---|---|---|
closed | Передаёт вызовы внешнему API и считает технические сбои | После успешного ответа счётчик сбрасывается |
open | Немедленно отклоняет вызовы без обращения к зависимости | Число вызовов внешнего API не растёт |
half-open | После паузы пропускает ограниченную пробу | Успех закрывает цепь, технический сбой снова открывает |
Эта модель соответствует описанию паттерна Circuit Breaker в Microsoft Learn: открытое состояние не лечит внешнюю систему, а даёт ей время восстановиться и защищает вызывающую сторону от каскада ожиданий.
Какие ошибки считать отказом зависимости
Тайм-аут соединения или чтения, когда подтверждён ответственный внешний узел.
Разрыв соединения и документированные серверные ответы о временной недоступности.
Исчерпание соединений или другая локальная причина — только после диагностики: такой сбой может быть внутри самого клиента.
Бизнес-отказ вроде «неизвестный SKU» или «недостаточно прав» не открывает цепь: сервис ответил, а повтор того же запроса без изменения данных не устранит причину.
По одному коду ответа выводить рано. Например, HTTP 429 может означать ограничение частоты: паузу задаёт политика поставщика и иногда заголовок Retry-After. HTTP 500 может быть как кратким сбоем, так и устойчивой ошибкой конкретного запроса. Классификатор ошибок должен быть отдельным и проверяемым правилом интеграции.
Стенд и начальные условия
CPython 3.12.14, только стандартная библиотека; проверка выполнена 27 октября 2026 года.
Изолированная модель без сети, файлов, базы данных и фоновых процессов.
Порог — три подряд технических сбоя; пауза перед пробой — 10 условных секунд.
Время управляется FakeClock, поэтому тест не ждёт реальные 10 секунд и воспроизводится одинаково.
Фиктивный API возвращает заранее заданную последовательность: бизнес-отказ, четыре технических сбоя, два успешных ответа.
Сохраните пример в новом временном каталоге как circuit_breaker_lab.py и запустите обычным пользователем. Он ничего не записывает и не удаляет вне самого файла с примером.
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}")
Ожидаемый и полученный вывод:
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
Процесс завершился с кодом 0. Все переходы дополнительно закреплены assert-проверками: если вызов всё-таки дойдёт до внешней зависимости при открытой цепи или счётчик не сбросится после успеха, пример остановится с ошибкой.
Что именно подтвердил прогон
Сценарий | Наблюдение | Критерий результата |
|---|---|---|
Бизнес-отказ | Состояние closed, технических сбоев 0 | Отказ запроса не ухудшает состояние зависимости |
Три технических сбоя | Состояние open после четвёртого общего вызова | Порог открывает цепь |
Вызов до истечения паузы | Счётчик зависимости остаётся 4 | Запрос заблокирован локально |
Неудачная проба | Состояние снова open, вызовов зависимости 5 | Пауза начинается заново |
Успешная проба | Состояние closed, вызовов зависимости 6 | Обычный режим восстановлен |
Следующий запрос | Успех, вызовов зависимости 7 | После восстановления трафик проходит |
Почему для паузы нужны монотонные часы
В производственном коде длительность паузы лучше измерять монотонными часами: они не идут назад при коррекции системного времени. В Python для этого предназначена функция time.monotonic. Календарное время оставьте для журналов и меток событий.
Учебный FakeClock реализует то же важное свойство: его значение меняется только явным advance. Это позволяет отдельно проверить момент до границы паузы и саму границу, не превращая тест в десятисекундное ожидание.
Circuit breaker не заменяет повтор
Повтор отвечает на вопрос «когда попробовать тот же вызов ещё раз». Circuit breaker отвечает на другой вопрос: «нужно ли сейчас вообще обращаться к зависимости». Если совместить оба механизма без общего бюджета, один пользовательский запрос может породить несколько попыток до открытия цепи, а очередь — много одинаковых волн нагрузки.
Если поставщик вернул 429 или 503 с Retry-After, сначала проверьте форму заголовка и рассчитанную паузу. Локальный reset_timeout не должен заставлять клиента обращаться раньше явно указанного допустимого момента.
Что добавить перед рабочим запуском
Согласовать список учитываемых ошибок для каждого внешнего метода, а не для API целиком.
Задать порог и паузу по наблюдаемому времени восстановления, лимитам поставщика и допустимой задержке бизнеса.
Ограничить число half-open проб при конкуренции. В нескольких процессах нужна общая атомарная координация либо отдельная цепь на процесс с понятными последствиями.
Разделить цепи по независимым зависимостям и операциям: сбой обновления остатков не обязан останавливать чтение статуса заказа.
Добавить метрики переходов closed/open/half-open, число локально отклонённых вызовов и результат проб.
Определить поведение вызывающего процесса: поставить задачу в очередь, вернуть контролируемую ошибку или использовать допустимые устаревшие данные.
Проверить восстановление после перезапуска приложения и синхронизацию состояния между экземплярами.
Для операции записи отдельно решите вопрос идемпотентности. Если клиент получил тайм-аут, внешний сервис мог применить изменение, хотя ответ не дошёл. Открытие цепи предотвращает новые вызовы, но не выясняет судьбу уже отправленной команды.
Границы модели
Проверен один процесс без потоков и асинхронных задач; конкурентный доступ к half-open состоянию не моделировался.
Не проверялись реальные сетевые библиотеки, DNS, TLS, прокси, лимиты соединений и формат ответов конкретного поставщика.
Порог 3 и пауза 10 секунд выбраны для учебного прогона, а не как универсальные рабочие значения.
Пример не сохраняет состояние между перезапусками и не координирует несколько экземпляров приложения.
Модель не повторяет запросы и не меняет данные: она проверяет только допуск вызова к зависимости.
Критерий готовности здесь простой: при устойчивом техническом сбое число реальных вызовов перестаёт расти, до конца паузы запросы отклоняются локально, неудачная проба не выпускает поток, а успешная возвращает обычный режим. Всё остальное — значения порога, общий или локальный счётчик и реакция бизнеса — нужно зафиксировать в контракте конкретной интеграции.
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
Agree on a list of errors to consider for each external method, not for the API as a whole.
Set thresholds and pauses based on observed recovery time, provider limits, and acceptable business latency.
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.
Separate chains by independent dependencies and operations: a failure in inventory updates should not stop order status reads.
Add metrics for closed/open/half-open transitions, the count of locally rejected calls, and probe results.
Determine the calling process's behavior: queue the task, return a controlled error, or use acceptable stale data.
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.