Каждый API уложился в тайм-аут, а заказ всё равно ждёт: проверяем общий deadline
Детерминированная модель показывает, почему локальные тайм-ауты не ограничивают всю цепочку, как общий deadline отдаёт следующему вызову только остаток времени и почему повтор расходует тот же бюджет.

В этой статье
Локальный тайм-аут каждого API не ограничивает время всей операции. Если каталог, доставка и программа лояльности последовательно получают по 100 мс, цепочка теоретически может ждать 300 мс — ещё до пауз и повторов. Общий deadline задаёт один крайний момент завершения и перед каждым следующим шагом уменьшает допустимое ожидание.
В учебной модели три вызова занимают по 70 мс. Каждый укладывается в локальные 100 мс, но вместе они требуют 210 мс при бюджете ответа 160 мс. С общим deadline третий шаг получает только оставшиеся 20 мс. Отдельный сценарий показывает, что тайм-аут первой попытки, пауза и повтор тоже расходуют тот же бюджет.
Чем deadline отличается от трёх тайм-аутов
Рекомендации Microsoft по временным сбоям предлагают учитывать общий период операции: время попыток и интервалы между повторами должны укладываться в заданный предел. Там же отдельно советуют задавать тайм-аут каждому исходящему вызову до добавления повторов.
В gRPC deadline и отмена задают момент, после которого вызов считается просроченным, и могут передаваться дочерним вызовам. Это пример конкретного протокола. Для обычного HTTP API тот же принцип придётся реализовать средствами используемого клиента и фреймворка.
Условия безопасной модели
- Среда: CPython 3.12.14, только стандартная библиотека.
- Сеть, файлы, база данных, рабочий магазин и реальные таймеры не используются.
- VirtualClock двигается только по заданным целым миллисекундам, поэтому результат детерминирован.
- Локальный тайм-аут одного вызова равен 100 мс, общий deadline — 160 мс.
- В сценарии повтора первая попытка получает 60 мс, пауза занимает 20 мс, а повтор — 50 мс.
- Модель ничего не записывает и не удаляет; после процесса временных данных нет.
Полный воспроизводимый расчёт
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class VirtualClock:
now_ms: int = 0
def spend(self, milliseconds: int) -> None:
if milliseconds < 0:
raise ValueError("milliseconds must be non-negative")
self.now_ms += milliseconds
@dataclass(frozen=True)
class CallResult:
name: str
status: str
spent_ms: int
remaining_ms: int | None
def call_with_local_timeout(
clock: VirtualClock,
*,
name: str,
duration_ms: int,
timeout_ms: int,
) -> CallResult:
spent = min(duration_ms, timeout_ms)
clock.spend(spent)
status = "ok" if duration_ms <= timeout_ms else "timeout"
return CallResult(name, status, spent, None)
def call_with_deadline(
clock: VirtualClock,
*,
name: str,
duration_ms: int,
per_call_timeout_ms: int,
deadline_ms: int,
) -> CallResult:
if duration_ms < 0 or per_call_timeout_ms <= 0:
raise ValueError("duration must be non-negative and timeout must be positive")
remaining = max(0, deadline_ms - clock.now_ms)
if remaining == 0:
return CallResult(name, "deadline_exceeded", 0, 0)
allowed = min(per_call_timeout_ms, remaining)
spent = min(duration_ms, allowed)
clock.spend(spent)
if duration_ms <= allowed:
status = "ok"
elif remaining <= per_call_timeout_ms:
status = "deadline_exceeded"
else:
status = "timeout"
return CallResult(
name,
status,
spent,
max(0, deadline_ms - clock.now_ms),
)
def scenario_local_only() -> list[CallResult]:
clock = VirtualClock()
results = [
call_with_local_timeout(
clock,
name=name,
duration_ms=70,
timeout_ms=100,
)
for name in ("catalog", "delivery", "loyalty")
]
assert [item.status for item in results] == ["ok", "ok", "ok"]
assert clock.now_ms == 210
print(f"local_only: statuses=ok,ok,ok total_ms={clock.now_ms}")
return results
def scenario_shared_deadline() -> list[CallResult]:
clock = VirtualClock()
results = [
call_with_deadline(
clock,
name=name,
duration_ms=70,
per_call_timeout_ms=100,
deadline_ms=160,
)
for name in ("catalog", "delivery", "loyalty")
]
assert [item.status for item in results] == [
"ok",
"ok",
"deadline_exceeded",
]
assert [item.spent_ms for item in results] == [70, 70, 20]
assert clock.now_ms == 160
print(
"shared_deadline: "
"catalog=ok delivery=ok loyalty=deadline_exceeded "
f"loyalty_spent_ms={results[-1].spent_ms} total_ms={clock.now_ms}"
)
return results
def scenario_retry_consumes_budget() -> list[CallResult]:
clock = VirtualClock()
results = [
call_with_deadline(
clock,
name="supplier_attempt_1",
duration_ms=80,
per_call_timeout_ms=60,
deadline_ms=160,
)
]
assert results[-1].status == "timeout"
backoff_ms = 20
remaining_before_backoff = 160 - clock.now_ms
if backoff_ms > remaining_before_backoff:
raise AssertionError("test setup has no room for backoff")
clock.spend(backoff_ms)
results.append(
call_with_deadline(
clock,
name="supplier_attempt_2",
duration_ms=50,
per_call_timeout_ms=60,
deadline_ms=160,
)
)
results.append(
call_with_deadline(
clock,
name="audit",
duration_ms=50,
per_call_timeout_ms=60,
deadline_ms=160,
)
)
assert [item.status for item in results] == [
"timeout",
"ok",
"deadline_exceeded",
]
assert [item.spent_ms for item in results] == [60, 50, 30]
assert clock.now_ms == 160
print(
"retry_budget: "
"attempt_1=timeout backoff_ms=20 attempt_2=ok "
"audit=deadline_exceeded audit_spent_ms=30 total_ms=160"
)
return results
def main() -> None:
scenario_local_only()
scenario_shared_deadline()
scenario_retry_consumes_budget()
if __name__ == "__main__":
main()
Сохраните код в новом пустом каталоге как deadline_budget_lab.py. Затем выполните команду:
python3 deadline_budget_lab.py
Два последовательных запуска завершились с кодом 0 и дали одинаковый вывод:
local_only: statuses=ok,ok,ok total_ms=210
shared_deadline: catalog=ok delivery=ok loyalty=deadline_exceeded loyalty_spent_ms=20 total_ms=160
retry_budget: attempt_1=timeout backoff_ms=20 attempt_2=ok audit=deadline_exceeded audit_spent_ms=30 total_ms=160
SHA-256 проверенного файла: e83a0ea001903e4b630a255c65947406ea93e251d61255ad4a4f2b10c7e20baf.
Что подтвердил расчёт
- Без общего deadline три исправных вызова завершаются за 210 мс: локальных ошибок нет, но бюджет ответа 160 мс уже нарушен.
- С deadline первые два шага занимают 140 мс, поэтому третьему остаётся 20 мс вместо локальных 100 мс.
- Первая попытка останавливается по локальному timeout через 60 мс: до общего deadline ещё 100 мс, поэтому это не deadline_exceeded. Пауза 20 мс и повтор 50 мс расходуют тот же бюджет и оставляют следующему шагу только 30 мс. Этот последний шаг уже останавливается по общему deadline.
- Нулевой остаток означает немедленную остановку нового вызова, а не ещё один полный локальный тайм-аут.

Числа учебные и не описывают производительность конкретного магазина. Модель проверяет арифметику бюджета и ветвление клиента; она не измеряет DNS, установление TLS, пул соединений, серверную очередь или отмену уже отправленного HTTP-запроса.
Повтор должен спросить разрешение у остатка времени
Проверять только число попыток мало. Перед паузой и каждой новой попыткой клиенту нужен остаток общего времени. Если рекомендуемая пауза больше остатка, новый вызов не стоит начинать: он заранее не может уложиться в договор. Серверный Retry-After тоже не продлевает deadline автоматически; для фоновой операции можно сохранить работу и продолжить позже по отдельному контракту.
Отмена ожидания на стороне клиента не доказывает, что внешняя система прекратила обработку. Изменяющие операции всё равно требуют идемпотентности или последующей сверки состояния. Иначе быстрый отказ интерфейса может скрывать медленное, но уже принятое изменение.
Как перенести механизм в интеграцию магазина
- Зафиксируйте общий бюджет для бизнес-операции: например, расчёт оформления заказа, обновление цены или фоновый импорт. Не смешивайте интерактивный ответ и многочасовую задачу.
- Перед каждым внешним вызовом вычисляйте остаток по монотонным часам и передавайте минимум из локального тайм-аута и остатка.
- Включайте в бюджет соединение, чтение ответа, паузы, повторы и собственную обработку между вызовами.
- Определите, какой результат можно пропустить или отложить. Цена доставки и необязательный бонус имеют разные последствия.
- Сохраняйте идентификатор операции, число попыток, израсходованное и оставшееся время без токенов и персональных данных.
- Для изменяющих вызовов проверяйте повтор после тайм-аута и отказ внешней системы; новый deadline не заменяет идемпотентность.
Для пользовательского модуля 1С-Битрикс: Управление сайтом это проектный приём, а не обещание штатной настройки. До реализации нужно зафиксировать редакцию и версию продукта, направление обмена, место выполнения кода, товар или торговое предложение, поля цены и остатка, права технического пользователя и реальные ограничения каждого API.
Общий deadline отвечает на вопрос «сколько времени осталось у всей операции». Circuit breaker решает соседнюю задачу: когда временно перестать обращаться к зависимому сервису после серии технических отказов.
Критерий приёмки
На тестовой среде цепочка должна завершаться не позже общего deadline с учётом заданного допуска измерения. После исчерпания бюджета новые вызовы и повторы не стартуют, уже начатые ожидания получают отмену по возможностям клиента, а неоднозначные изменяющие операции сверяются по устойчивому идентификатору. Если журнал показывает только отдельные тайм-ауты и не показывает общий расход, система пока умеет считать деревья, но теряет лес.
A local timeout for each API does not limit the total operation time. If the catalog, delivery, and loyalty program each receive 100 ms sequentially, the chain could theoretically wait 300 ms before any pauses or retries. A global deadline sets a single completion cutoff and reduces the allowable wait time before each subsequent step.
In the training model, three calls take 70 ms each. Each fits within the local 100 ms limit, but together they require 210 ms against a 160 ms response budget. With a global deadline, the third step receives only the remaining 20 ms. A separate scenario shows that a timeout on the first attempt, followed by a pause and retry, also consumes the same budget.
How a deadline differs from three timeouts
Microsoft recommendations on transient failures advise considering the total operation period: the time for attempts and intervals between retries must fit within the specified limit. They also separately recommend setting a timeout for each outgoing call before adding retries.
The gRPC deadline and cancellation define the point after which a call is considered expired and can be passed to child calls. This is an example of a specific protocol. For a standard HTTP API, the same principle must be implemented using the tools provided by the client and framework in use.
Conditions for a safe model
- Environment: CPython 3.12.14, standard library only.
- Network, files, database, active shop, and real timers are not used.
- VirtualClock advances only in specified whole milliseconds, so the result is deterministic.
- The local timeout for a single call is 100 ms; the overall deadline is 160 ms.
- In the retry scenario, the first attempt gets 60 ms, the pause takes 20 ms, and the retry gets 50 ms.
- The model writes and deletes nothing; no temporary data remains after the process.
Fully reproducible calculation
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class VirtualClock:
now_ms: int = 0
def spend(self, milliseconds: int) -> None:
if milliseconds < 0:
raise ValueError("milliseconds must be non-negative")
self.now_ms += milliseconds
@dataclass(frozen=True)
class CallResult:
name: str
status: str
spent_ms: int
remaining_ms: int | None
def call_with_local_timeout(
clock: VirtualClock,
*,
name: str,
duration_ms: int,
timeout_ms: int,
) -> CallResult:
spent = min(duration_ms, timeout_ms)
clock.spend(spent)
status = "ok" if duration_ms <= timeout_ms else "timeout"
return CallResult(name, status, spent, None)
def call_with_deadline(
clock: VirtualClock,
*,
name: str,
duration_ms: int,
per_call_timeout_ms: int,
deadline_ms: int,
) -> CallResult:
if duration_ms < 0 or per_call_timeout_ms <= 0:
raise ValueError("duration must be non-negative and timeout must be positive")
remaining = max(0, deadline_ms - clock.now_ms)
if remaining == 0:
return CallResult(name, "deadline_exceeded", 0, 0)
allowed = min(per_call_timeout_ms, remaining)
spent = min(duration_ms, allowed)
clock.spend(spent)
if duration_ms <= allowed:
status = "ok"
elif remaining <= per_call_timeout_ms:
status = "deadline_exceeded"
else:
status = "timeout"
return CallResult(
name,
status,
spent,
max(0, deadline_ms - clock.now_ms),
)
def scenario_local_only() -> list[CallResult]:
clock = VirtualClock()
results = [
call_with_local_timeout(
clock,
name=name,
duration_ms=70,
timeout_ms=100,
)
for name in ("catalog", "delivery", "loyalty")
]
assert [item.status for item in results] == ["ok", "ok", "ok"]
assert clock.now_ms == 210
print(f"local_only: statuses=ok,ok,ok total_ms={clock.now_ms}")
return results
def scenario_shared_deadline() -> list[CallResult]:
clock = VirtualClock()
results = [
call_with_deadline(
clock,
name=name,
duration_ms=70,
per_call_timeout_ms=100,
deadline_ms=160,
)
for name in ("catalog", "delivery", "loyalty")
]
assert [item.status for item in results] == [
"ok",
"ok",
"deadline_exceeded",
]
assert [item.spent_ms for item in results] == [70, 70, 20]
assert clock.now_ms == 160
print(
"shared_deadline: "
"catalog=ok delivery=ok loyalty=deadline_exceeded "
f"loyalty_spent_ms={results[-1].spent_ms} total_ms={clock.now_ms}"
)
return results
def scenario_retry_consumes_budget() -> list[CallResult]:
clock = VirtualClock()
results = [
call_with_deadline(
clock,
name="supplier_attempt_1",
duration_ms=80,
per_call_timeout_ms=60,
deadline_ms=160,
)
]
assert results[-1].status == "timeout"
backoff_ms = 20
remaining_before_backoff = 160 - clock.now_ms
if backoff_ms > remaining_before_backoff:
raise AssertionError("test setup has no room for backoff")
clock.spend(backoff_ms)
results.append(
call_with_deadline(
clock,
name="supplier_attempt_2",
duration_ms=50,
per_call_timeout_ms=60,
deadline_ms=160,
)
)
results.append(
call_with_deadline(
clock,
name="audit",
duration_ms=50,
per_call_timeout_ms=60,
deadline_ms=160,
)
)
assert [item.status for item in results] == [
"timeout",
"ok",
"deadline_exceeded",
]
assert [item.spent_ms for item in results] == [60, 50, 30]
assert clock.now_ms == 160
print(
"retry_budget: "
"attempt_1=timeout backoff_ms=20 attempt_2=ok "
"audit=deadline_exceeded audit_spent_ms=30 total_ms=160"
)
return results
def main() -> None:
scenario_local_only()
scenario_shared_deadline()
scenario_retry_consumes_budget()
if __name__ == "__main__":
main()
Save the code in a new empty directory as deadline_budget_lab.py. Then run the command:
python3 deadline_budget_lab.py
Two sequential runs completed with exit code 0 and produced identical output:
local_only: statuses=ok,ok,ok total_ms=210
shared_deadline: catalog=ok delivery=ok loyalty=deadline_exceeded loyalty_spent_ms=20 total_ms=160
retry_budget: attempt_1=timeout backoff_ms=20 attempt_2=ok audit=deadline_exceeded audit_spent_ms=30 total_ms=160
SHA-256 of the verified file: e83a0ea001903e4b630a255c65947406ea93e251d61255ad4a4f2b10c7e20baf.
What the calculation confirmed
- Without an overall deadline, three correct calls complete in 210 ms: there are no local errors, but the 160 ms response budget is already exceeded.
- With a deadline, the first two steps take 140 ms, leaving only 20 ms for the third step instead of the local 100 ms.
- The first attempt stops due to a local timeout after 60 ms: with 100 ms remaining until the overall deadline, this is not a deadline_exceeded error. The 20 ms pause and 50 ms retry consume the same budget, leaving only 30 ms for the next step. This final step stops due to the overall deadline.
- A zero remainder means an immediate stop for a new call, not another full local timeout.

The numbers are for training and do not describe the performance of a specific store. The model checks the arithmetic of the budget and client branching; it does not measure DNS, TLS handshakes, connection pools, server queues, or cancellation of an already sent HTTP request.
A retry must request permission from the remaining time
Checking only the number of attempts is insufficient. Before a pause and before each new attempt, the client needs the remaining shared time. If the recommended pause exceeds the remainder, starting a new call is not worthwhile: it cannot fit within the agreement beforehand. The server Retry-After header also does not automatically extend the deadline; for a background operation, you can save the work and resume later under a separate contract.
Client-side cancellation does not prove that the external system has stopped processing. Mutating operations still require idempotency or subsequent state reconciliation. Otherwise, a fast interface failure may hide a slow but already accepted change.
How to port the mechanism to a store integration
- Fix a total budget for a business operation: for example, order checkout calculation, price updates, or background imports. Do not mix interactive responses with multi-hour tasks.
- Before each external call, calculate the remaining time using monotonic clocks and pass the minimum of the local timeout and the remaining time.
- Include connection, response reading, pauses, retries, and your own processing between calls in the budget.
- Determine which results can be skipped or deferred. Delivery price and an optional bonus have different consequences.
- Save the operation ID, number of attempts, time consumed and remaining time without tokens or personal data.
- For mutating calls, check for retries after a timeout or external system failure; a new deadline does not replace idempotency.
For the 1C-Bitrix: Site Manager user module, this is a project approach, not a promise of standard configuration. Before implementation, you must fix the product edition and version, the exchange direction, the code execution location, the product or SKU, price and stock fields, the technical user's permissions, and the actual limitations of each API.
The global deadline answers the question: 'How much time remains for the entire operation?' Circuit breaker addresses the adjacent task: when to temporarily stop calling a dependent service after a series of technical failures.
Acceptance criteria
On the test environment, the chain must complete no later than the overall deadline, accounting for the specified measurement tolerance. Once the budget is exhausted, new calls and retries do not start; already initiated waits are cancelled based on client capabilities, and ambiguous mutating operations are verified against a stable identifier. If the log shows only individual timeouts and not the total consumption, the system can still count the trees but has lost the forest.





Обсуждение 0
Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.
Пока никто не написал. Начни обсуждение.