How a Missing HTTP Timeout Let a Dead Webhook Endpoint Quietly Eat Our Entire Job Worker Pool
← Back
November 8, 2026Database9 min read

How a Missing HTTP Timeout Let a Dead Webhook Endpoint Quietly Eat Our Entire Job Worker Pool

Published November 8, 20269 min read

04:58 UTC. The on-call phone lights up with a single alert: webhook delivery queue depth has crossed 40,000 and is climbing in a straight line. No deploy in the last eighteen hours. No traffic spike. Worker CPU graphs are flat and low, like the pool is barely doing anything, which is strange for a queue that's supposedly backing up this fast.


the scramble

First theory: a worker crash loop. The orchestrator dashboard says otherwise, all 24 worker processes show as running, no restarts in the last six hours. Second theory: Postgres itself is slow, maybe a bad query plan after an autovacuum ran long. A quick check of pg_stat_activity shows nothing unusual on CPU or I/O wait. Third theory, the standard one for "queue won't drain": something is holding a lock that everything else is stuck behind.

$ psql -c "select pid, wait_event_type, wait_event, query from pg_stat_activity where wait_event_type = 'Lock';"
 pid | wait_event_type | wait_event | query
-----+------------------+------------+-------
(0 rows)

Zero rows. Nobody is waiting on a lock. That's the first dead end, and it's a convincing one, because this is the exact query every Postgres incident runbook tells you to run first. It comes back clean because the jobs table was deliberately designed to never show contention here.


the hunt

The job queue is a plain Postgres table, jobs(id, tenant_id, webhook_url, payload, status, created_at), and every worker claims the next row with the same query:

worker.py, the claim query running on all 24 workers
BEGIN;

SELECT id, tenant_id, webhook_url, payload
FROM jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1;

SKIP LOCKED is the whole point of this design: if another worker already has a row locked, you skip it and grab the next one instead of blocking. That's why the lock-wait query above returns nothing, nobody blocks on anything by construction. But a lock that's held for five hours and never contended for also never shows up anywhere a dashboard would look. Checking pg_locks directly for long-held grants, not waiters, tells a different story:

$ psql -c "select l.pid, a.state, now() - a.xact_start as held_for, a.query from pg_locks l join pg_stat_activity a using (pid) where l.relation = 'jobs'::regclass and l.granted order by a.xact_start limit 5;"
 pid  | state  | held_for | query
------+--------+----------+------------------------------------------
 9142 | active | 05:11:38 | SELECT id, tenant_id, webhook_url ...
 9188 | active | 04:47:02 | SELECT id, tenant_id, webhook_url ...
 9201 | active | 04:22:51 | SELECT id, tenant_id, webhook_url ...
 9340 | active | 03:58:14 | SELECT id, tenant_id, webhook_url ...

Four backends, all showing the same claim query, all stuck for hours, all with state = active rather than idle, meaning each one is still waiting on something inside the transaction, not sitting idle after it. Pulling the application logs for those four PIDs shows every one of them in the exact same place: a requests.post() call to the same webhook_url, for the same tenant_id. One customer's integration endpoint stopped responding at some point the previous evening, no TCP reset, no connection close, just silence after the handshake completed, almost certainly a misconfigured firewall on their side dropping the response rather than rejecting the request outright. The worker's HTTP client had never been given a timeout, so the call simply never returned.

That explains one stuck worker. It does not explain a pool-wide outage, because SKIP LOCKED means a stuck row costs you exactly one worker, not the whole queue. The second part of the picture comes from the jobs table itself: this customer's integration retries failed deliveries by enqueuing a fresh row, and new events for that tenant kept arriving all night at a normal rate. Every one of those new jobs got claimed successfully, by a different, healthy worker, because SKIP LOCKED let them skip right past the already-stuck rows. Then each of those workers made the same requests.post() call to the same dead endpoint, with the same missing timeout, and got stuck too.


the find

A single customer's webhook endpoint went silent without closing the connection, and the worker code held a database transaction open across an HTTP call with no timeout. SKIP LOCKED correctly prevented that from blocking any other job, which is exactly why nobody could see it happening: each new job for that tenant recruited one more worker into the same hang, one at a time, over roughly five hours, until all 24 workers in the fixed-size pool were permanently stuck on the same dead endpoint. At that point there were zero workers left to process jobs for any tenant, and the queue depth alert that fired at 04:58 UTC was the first moment the damage became visible outside this one customer's own traffic.


the fix

The immediate fix was blunt: restart the entire worker pool. Killing the processes drops the hung sockets and the open Postgres transactions with them, which clears every stuck row's lock at once.

$ kubectl rollout restart deployment/webhook-workers
deployment.apps/webhook-workers restarted

Queue depth started dropping within two minutes, and the backlog that had built up across every tenant drained in about eleven minutes once a full pool of healthy workers was processing again. The real fix had two parts. First, every outbound call gets an explicit timeout, no exceptions:

worker.py, after
session = requests.Session()
response = session.post(
    job.webhook_url,
    json=job.payload,
    timeout=(3, 10),  # connect, read
)

Second, and more important: the HTTP call no longer happens inside the claiming transaction at all. A worker now claims a job and commits immediately, marking it in_progress with a lease timestamp, does the HTTP call outside any open transaction, and only opens a second, short transaction afterward to record success or failure:

worker.py, lease pattern
with conn.transaction():
    job = claim_and_mark_in_progress(conn)  # commits here, no lock held after

if job:
    try:
        response = session.post(job.webhook_url, json=job.payload, timeout=(3, 10))
        outcome = 'done' if response.ok else 'failed'
    except requests.RequestException:
        outcome = 'failed'

    with conn.transaction():
        finalize_job(conn, job.id, outcome)

Even a client that still somehow hangs forever now only holds a worker process, never a database row lock or an open transaction, and a reaper job reclaims anything stuck in in_progress past a 60-second lease.


the aftermath

24 / 24 workers consumed before the alert fired
4h 52m from first stuck worker to total pool exhaustion

No jobs were lost, every pending delivery eventually went out once the pool restarted, but webhook delivery for every tenant, not just the one with the broken endpoint, stalled for roughly fifty minutes while the on-call team chased the wrong diagnostic first.

  • SKIP LOCKED hides contention by design. That's correct behavior for the queue, but it also means the standard "find the blocking lock" runbook will come back clean even when a lock held for hours is the actual root cause.
  • A fixed-size worker pool with no per-destination limit lets a single bad downstream endpoint consume the whole pool, one request at a time, as long as new work for that destination keeps arriving.
  • A lease-based claim with no transaction held across any outbound network call is now a hard rule for every worker in this codebase. Alerting was also changed from raw queue depth to oldest-unclaimed-job-age per tenant, so one customer's dead endpoint surfaces in minutes instead of hours.

timeout=(3, 10) would have stopped this in its first minute. The dangerous part wasn't the missing timeout on its own, it was pairing that gap with a claim pattern that let one slow call hold a database transaction open for as long as the network stayed quiet, and a queueing primitive specifically built to make that invisible until it had already taken down the whole pool.

Share this
← All Posts9 min read