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.
Article9 min readViews1

Every API met its timeout, yet the order still waits: checking the global deadline

A deterministic model explains why local timeouts do not bound the entire chain, how a global deadline allocates only the remaining time to the next call, and why retries consume the same budget.

Comments 0

Three mechanisms sequentially deplete the purple light beam until the yellow point at the final shutter
In this article

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.
Three API call timelines with a shared boundary of 160 milliseconds and a pause between attempts shown separately
Training model, scale in milliseconds. Row 1: A, B, and C each at 70 ms yield 210 ms with individual timeouts of 100 ms. Row 2: a shared deadline of 160 ms leaves C only 20 ms after A and B. Row 3: the first attempt A1 consumes 60 ms and ends due to the local timeout; Δ is a 20 ms pause, A2 is a retry taking 50 ms, and B receives the remaining 30 ms. The dashed line marks the shared deadline, not the local timeout of the first attempt.

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.

Discussion 0

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

No comments yet. Start the discussion.