Propagating Request Deadlines into Connection Acquisition

This guide is part of Connection Acquisition Timeout Strategies. A fixed acquisition timeout assumes every caller has the same patience. Real systems do not work that way: an interactive request may have 300 ms left by the time it reaches the data layer, while a batch job can wait ten seconds. When the pool uses one static number, the saturated case produces the worst of both — the interactive caller waits far past the point where its own client gave up, and the work it eventually performs is thrown away:

gateway  504 after 1,000 ms      (client gone)
service  acquired a connection at 2,400 ms, query completed at 2,460 ms, response discarded
pool     still 40 waiters, all of them already past their callers' deadlines

Wasted work under saturation is what turns a brief overload into a long one. The objective is to carry the caller’s deadline down into acquisition and query execution so that work begins only if it can still be delivered, and the pool queue drains instead of growing.

Rapid incident diagnosis

Compare the time spent waiting with the caller’s timeout. If p99 acquisition wait is close to or above the upstream timeout, the service is performing work nobody is waiting for:

histogram_quantile(0.99, sum by (le) (rate(hikaricp_connections_acquire_seconds_bucket[5m])))
# compare with the gateway's configured request timeout

Count responses that arrive after the client disconnected. Most servers can report it; in Express, the close event on the request fires when the client goes away:

app.use((req, res, next) => {
  req.on("close", () => { if (!res.writableEnded) metrics.abandoned.inc(); });
  next();
});

A rising abandoned count during saturation is direct evidence of wasted work.

Look for a queue that never drains. A pool where the number of waiters stays constant while requests time out is serving a queue of already-dead requests: each new arrival joins the back, each timeout leaves from the front, and the connection released in between goes to a request that is also too late.

Check whether the deadline exists at all. Many services have no notion of remaining time: the HTTP handler calls the data layer, which uses the pool’s static timeout. Grep for the pool’s acquisition setting and see whether any call site can override it:

grep -rn "connectionTimeout\|pool_timeout\|acquire(timeout\|WithTimeout\|CommandTimeout" --include=*.java --include=*.py --include=*.go --include=*.ts .
Wasted work under saturation without deadline propagation With a fixed three second acquisition timeout and a one second gateway timeout, requests acquire connections long after their callers disconnected, so the work performed produces no delivered responses. One request under saturation queued for a connection gateway gives up at 1 s query runs response discarded Effect on the pool every connection released goes to another request that is also past its deadline, so the queue length stays constant while delivered throughput falls to near zero the fix is to stop work that cannot be delivered, not to add connections
Without deadline propagation, a saturated pool keeps serving requests whose callers have already given up, so useful throughput falls while the queue stays full.

Mathematical sizing or parameter formula

The budget is what remains of the caller’s deadline after the time already spent:

remaining        = deadline − elapsed
acquire_budget   = remaining × acquire_share − safety_margin
query_budget     = remaining − acquire_time_used − response_margin

A sensible acquire_share is 0.3 to 0.5: if acquisition consumes more than half the remaining budget, the query that follows is unlikely to finish in time anyway. With a 900 ms remaining budget, a 40 ms safety margin and a 0.4 share: acquire_budget = 320 ms, and whatever time is actually spent waiting is subtracted before the query timeout is set.

Queue behaviour improves because arrivals leave earlier. Under saturation, a queue of waiters with per-request budgets drains at the rate the shortest budgets expire, so the effective queue length is bounded by:

effective_waiters ≈ arrival_rate × mean_acquire_budget

At 400 requests per second and a mean budget of 320 ms, about 128 waiters can exist at once, each of which will still be useful if served. With a static three-second timeout, the same arrival rate supports 1,200 waiters, nearly all of which are past their deadlines by the time they are served.

Deadlines must shrink across hops. Each service in a chain should pass a slightly smaller deadline than it received, so the innermost call cannot outlive the outermost:

downstream_deadline = received_deadline − network_margin − local_overhead

With 10 ms of network margin per hop and three hops, the database layer of the innermost service works with about 30 ms less than the client allowed — a small price for never exceeding it.

Queue length with a static timeout versus propagated deadlines Under the same arrival rate, a static three second timeout allows around 1,200 waiters, while per-request budgets averaging 320 milliseconds bound the queue near 128. Waiters in the pool queue at 400 requests per second static 3 s timeout ~1,200 propagated budgets ~128 the shorter queue is not a capacity change: the same connections serve the same rate the difference is that every request served still has a caller waiting for it waiters = arrival rate × mean acquisition budget
With per-request budgets, waiters leave the queue as their deadlines expire, so the queue stays short and the connections that free up go to requests that can still be delivered.

Exact remediation & configuration

Go has the cleanest model, because context.Context carries the deadline and database/sql honours it in both acquisition and execution:

func (s *Server) handleOrder(w http.ResponseWriter, r *http.Request) {
    // The server sets a deadline for the whole request; derive the database budget from it
    ctx := r.Context()
    if dl, ok := ctx.Deadline(); ok {
        remaining := time.Until(dl)
        budget := time.Duration(float64(remaining) * 0.4)
        var cancel context.CancelFunc
        ctx, cancel = context.WithTimeout(ctx, budget)
        defer cancel()
    }

    // QueryContext waits for a connection only until ctx expires, then returns immediately
    row := s.db.QueryRowContext(ctx, "SELECT status FROM orders WHERE id = $1", chi.URLParam(r, "id"))
    // ...
}

The important property is that database/sql returns context.DeadlineExceeded from the acquisition wait, not only from the query, so an expired request never occupies a connection.

Python with psycopg_pool, where the timeout is a parameter of the borrow:

import time
from psycopg_pool import PoolTimeout

def with_budget(pool, deadline_monotonic, share=0.4, margin=0.04):
    remaining = deadline_monotonic - time.monotonic()
    if remaining <= margin:
        raise DeadlineExceeded()
    budget = max(0.01, remaining * share - margin)
    return pool.connection(timeout=budget)

with with_budget(pool, request_deadline) as conn:
    conn.execute("SET LOCAL statement_timeout = %s", (int((request_deadline - time.monotonic()) * 1000),))
    rows = conn.execute("SELECT id, total FROM invoices WHERE customer_id = %s", (cid,)).fetchall()

Setting statement_timeout from the same budget means the database also stops work that can no longer be delivered.

Java with HikariCP, which has no per-call acquisition timeout, so the deadline is enforced around it:

public <T> T withBudget(Duration remaining, SqlFunction<T> work) throws SQLException {
    long budgetMs = Math.max(10, (long) (remaining.toMillis() * 0.4) - 40);
    // A dedicated DataSource per budget class is the supported way to vary the wait
    DataSource ds = budgetMs < 500 ? fastDataSource : batchDataSource;
    long start = System.nanoTime();
    try (Connection c = ds.getConnection()) {
        long usedMs = (System.nanoTime() - start) / 1_000_000;
        long queryMs = Math.max(10, remaining.toMillis() - usedMs - 40);
        try (Statement s = c.createStatement()) {
            s.execute("SET LOCAL statement_timeout = " + queryMs);
        }
        return work.apply(c);
    }
}

Two data sources — one with a short connectionTimeout for interactive traffic, one with a longer one for batch work — is the practical way to express two budget classes when the pool cannot vary the wait per call.

Node.js, where the deadline is carried explicitly and races the acquisition:

async function withBudget(pool, deadlineMs, fn) {
  const remaining = deadlineMs - Date.now();
  if (remaining <= 40) throw Object.assign(new Error("deadline exceeded"), { status: 504 });
  const budget = Math.max(10, remaining * 0.4 - 40);

  const acquiring = pool.connect();
  const timer = new Promise((_, rej) => setTimeout(() => rej(new PoolBusy()), budget));
  let client;
  try {
    client = await Promise.race([acquiring, timer]);
  } catch (e) {
    acquiring.then((late) => late.release()).catch(() => {});   // never leak a late arrival
    throw e;
  }
  try {
    await client.query(`SET LOCAL statement_timeout = ${Math.max(10, deadlineMs - Date.now() - 40)}`);
    return await fn(client);
  } finally {
    client.release();
  }
}

Carry the deadline between services. Propagate a header — grpc-timeout in gRPC, or a X-Request-Deadline epoch millisecond value over HTTP — and have each service derive its local deadline from it rather than from its own default.

Rolling it out without breaking batch work

Deadline propagation fails badly when applied uniformly: a nightly job with no deadline inherits a short default and starts failing.

Classify traffic first. Interactive requests have a client waiting and a deadline worth honouring. Background jobs, migrations and exports have no client and need generous or unlimited budgets. Queue consumers sit in between: they have no user, but a message that takes too long should be retried rather than blocking the consumer.

BUDGETS = {
    "interactive": {"share": 0.4, "max": 1.0},
    "queue":       {"share": 0.5, "max": 10.0},
    "batch":       {"share": 1.0, "max": 120.0},
}

Default to the old behaviour where no deadline exists. Code paths that do not set a deadline should use the pool’s static timeout, not a zero budget. A missing deadline is a gap in instrumentation, not an instruction to fail fast.

Roll out on read paths first. Reads are usually idempotent, so a request abandoned at the acquisition stage costs nothing. Write paths need the same care as any retry: an operation that may already have reached the database must be idempotent before the caller retries it.

Watch the error mix, not just the error rate. A successful rollout converts slow successes into fast failures; the total error count may rise while user-visible latency improves and wasted work falls. Track delivered responses per second — responses whose client was still connected — as the outcome that matters.

Traffic class Deadline source Budget Behaviour when exceeded
Interactive request Gateway timeout, propagated header 40 % of remaining Fast 503 with Retry-After
Queue consumer Visibility timeout ÷ 2 50 % of remaining Message returns to the queue
Scheduled job Job runtime limit generous, capped Job fails and is retried on schedule
Migration none unlimited Runs to completion
Budget classes by traffic type Interactive requests receive a short budget derived from the gateway timeout, queue consumers a larger one derived from the visibility timeout, and batch or migration work keeps generous or unlimited budgets. Interactive 40 % of remaining fast 503 Queue consumer 50 % of remaining message retried Scheduled job generous, capped job retried Migration unlimited runs to completion One mechanism, four budgets a path with no deadline keeps the pool's static timeout rather than failing immediately
Not every caller has a deadline worth honouring. Classify traffic first, and let paths without a deadline keep the static timeout.

Validation & verification

Expired requests never acquire. In staging, cap the pool at one connection, hold it, and send requests with a 200 ms deadline. Every one must fail in about 200 ms, and the pool’s acquisition counter must not record a successful checkout for them after they expired.

Delivered throughput improves under saturation. Run a saturation test with and without propagation, measuring responses delivered to still-connected clients per second. The propagated run should deliver at least as many, with far lower p99 latency and far fewer abandoned responses.

Downstream deadlines shrink. Log the deadline at each hop and confirm it decreases monotonically along the chain:

gateway  deadline=+1000ms
api      deadline=+980ms   acquire_budget=376ms
worker   deadline=+960ms   acquire_budget=368ms

Batch work is unaffected. Run the nightly job with propagation enabled and confirm it completes with the same runtime as before, which proves that the batch class is not inheriting an interactive budget.

Frequently Asked Questions

Does cancelling an acquisition leave a connection half-borrowed?
Not in pools that support cancellation properly. Go’s database/sql removes the waiter from its queue; psycopg’s pool returns the connection it was about to hand over to the next waiter. The dangerous case is an application-level race in Node.js, where a connection can arrive after the caller gave up — which is why the example above releases a late arrival explicitly.
Should the query timeout be derived from the deadline too?
Yes. Acquiring a connection and then running a query with a static timeout simply moves the wasted work from the pool into the database. Setting statement_timeout or the driver’s command timeout from the remaining budget completes the chain.
What if the caller sends no deadline?
Use the pool’s static timeout. Inventing a short default for callers that never asked for one converts a working path into a failing one.
Does this replace circuit breaking?
No, they are complementary. Deadlines stop individual wasted requests; a circuit breaker stops the flood when the dependency is broken. The pattern for the latter is in Implementing Circuit Breakers for Connection Acquisition Failures.