How an Unpropagated gRPC Deadline Turned a Traffic Spike Into a Retry Storm That Exhausted Our Feature Store
← Back
September 5, 2026Python9 min read

How an Unpropagated gRPC Deadline Turned a Traffic Spike Into a Retry Storm That Exhausted Our Feature Store

Published September 5, 20269 min read

14:32 UTC. PagerDuty: "p99 latency on /recommendations: 9.4s (threshold 1.5s)." Two minutes earlier it had been 240ms. Nobody had deployed anything since the previous afternoon.


the setup

reco-svc renders the "recommended for you" module on the homepage. For every request it makes one blocking call over gRPC to feature-svc, an internal service that joins a user's recent activity against a precomputed embedding table and returns a feature vector. feature-svc is a plain synchronous gRPC server: grpc.server(futures.ThreadPoolExecutor(max_workers=32)), 32 worker threads, one request per thread for the duration of the handler.

Three weeks earlier, feature-svc had picked up a new input to that vector: a "session affinity score" that needed an extra join against a rolling 30-minute activity window. It made recommendations measurably better in the offline eval, and it moved the handler's typical latency from p50 120ms / p99 280ms to p50 210ms / p99 340ms. Still comfortably under reco-svc's 300ms client-side deadline on a normal day, so nobody flagged it as a capacity change. It was a quality improvement that happened to spend most of the margin between "usually fine" and "right at the edge."


the scramble

An email campaign had gone out at 14:30 UTC. Click-through drove homepage traffic to roughly 4.2x baseline for about forty minutes, entirely expected and sized for in advance. First theory: reco-svc's own Postgres pool was the bottleneck, since it also reads user history for the fallback path. SELECT count(*) FROM pg_stat_activity WHERE state = 'active' showed nothing unusual, connections nowhere near the pool limit.

Second theory: feature-svc's Redis layer, which caches the embedding lookups, was thrashing under the extra load. Cache hit rate was steady at 98.1%, identical to the daily average. Redis wasn't struggling either.

Fifteen minutes in, with the two obvious suspects cleared, someone pulled up feature-svc's own dashboard instead of guessing from reco-svc's side of the call.


the hunt

feature-svc exports a gauge for active worker threads. It had been pinned at 32 out of 32 since roughly 14:34, four minutes after the campaign send, and hadn't moved since.

feature-svc worker gauge, 14:20-14:45
14:20  grpc_server_workers_active 6
14:25  grpc_server_workers_active 9
14:30  grpc_server_workers_active 14
14:34  grpc_server_workers_active 32
14:36  grpc_server_workers_active 32
14:40  grpc_server_workers_active 32
14:45  grpc_server_workers_active 32

Fully saturated and flat is a different shape than "busy." Busy under load usually still shows some churn as requests finish and new ones take their place. Flat at the ceiling for ten straight minutes meant requests were entering the pool and not leaving it. A py-spy dump against a feature-svc pod confirmed it: 32 threads, all inside compute_features, several of them showing wall-clock time in the handler well past a full second.

the handler as written
def compute_features(self, request, context):
    activity = fetch_recent_activity(request.user_id)          # ~40ms
    embeddings = fetch_embeddings(request.user_id)              # redis, ~5ms
    affinity = compute_session_affinity(activity, embeddings)   # pandas merge, 150-900ms
    return build_feature_vector(embeddings, affinity)

Nothing in that handler ever looked at context again after receiving it. On reco-svc's side, the client's 300ms deadline was tripping constantly under the spike, since the new feature had already pushed p99 latency close to the ceiling before any extra load showed up. Every DEADLINE_EXCEEDED triggered a client-side retry, per the channel's default service config: three attempts, 50ms initial backoff, no jitter.

Here's where the two problems compounded. reco-svc gave up on a slow call at 300ms and fired a retry almost immediately. feature-svc had no idea the original caller was gone. It kept running compute_session_affinity to completion anyway, because nothing in the handler ever checked context.is_active(), and the pandas merge underneath it doesn't know how to cancel itself. That thread stayed occupied until the abandoned work finished, sometimes 900ms later, while the retry landed on the pool as a brand-new request competing for one of the already-shrinking set of free workers. Demand went up, effective capacity went down, and neither side of that could see the other happening.


the find

Root cause: a deadline set on the client had no representation on the server at all. context carries the deadline and cancellation state across the wire, but compute_features never consulted it, so work kept running long after the only caller waiting on it had already stopped waiting and asked again. Combined with a retry policy that added load back onto the server within 50ms of the failure it was reacting to, the system had no mechanism to shed the requests nobody wanted anymore, and every retry made the queue feeding the thread pool longer instead of shorter.

The session affinity feature didn't cause the incident by itself. It removed the margin that had been quietly absorbing the fact that deadline propagation was never implemented in the first place. The traffic spike was just the first time that margin ran out.


the fix

First, the handler checks whether the caller is still there before doing the expensive part of the work, and again at the one natural break point in the middle of it.

feature-svc/handlers.py (after)
def compute_features(self, request, context):
    if not context.is_active():
        context.abort(grpc.StatusCode.CANCELLED, "caller already gone")

    activity = fetch_recent_activity(request.user_id)
    embeddings = fetch_embeddings(request.user_id)

    if not context.is_active():
        context.abort(grpc.StatusCode.CANCELLED, "caller already gone")

    affinity = compute_session_affinity(activity, embeddings)
    return build_feature_vector(embeddings, affinity)

This alone frees threads faster once a caller has already given up, but it doesn't stop a saturated pool from accepting more work than it can finish. Second change: reject new requests outright once the pool is near capacity, instead of letting them queue behind work that may already be abandoned.

feature-svc/server.py
_active = threading.Semaphore(28)  # leave headroom below max_workers=32

def compute_features(self, request, context):
    if not _active.acquire(blocking=False):
        context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED, "at capacity")
    try:
        return _compute_features_inner(request, context)
    finally:
        _active.release()

Third, reco-svc's retry policy stopped amplifying load during exactly the window when the server was struggling most.

reco-svc grpc service config (after)
{
  "methodConfig": [{
    "name": [{ "service": "feature.FeatureService" }],
    "retryPolicy": {
      "maxAttempts": 2,
      "initialBackoff": "0.1s",
      "maxBackoff": "1s",
      "backoffMultiplier": 2.0,
      "retryableStatusCodes": ["UNAVAILABLE", "DEADLINE_EXCEEDED"]
    }
  }]
}

Two attempts instead of three, a slower and growing backoff instead of a flat 50ms, and a RESOURCE_EXHAUSTED response now fails fast on the client without triggering another retry at all, since it isn't in the retryable list. A caller that gets told "at capacity" gives up and falls back immediately, instead of adding to the exact queue that just rejected it.


the aftermath

31 min From page to recovery
32/32 Worker threads saturated for 26 of those minutes
640 Peak queued RPCs waiting on the pool
3,100 Homepage loads served the generic "trending" fallback

No request failed outright. reco-svc's own timeout falls back to a static trending list when feature-svc doesn't answer in time, so the visible damage was 3,100 homepage loads with degraded, non-personalized recommendations rather than an outage. The gauge for active workers is now on the same dashboard as request rate, specifically so "flat at the ceiling" stands out against "busy and churning" at a glance instead of needing a py-spy dump to notice the difference.

  • A deadline is a client-side promise unless the server explicitly checks it. gRPC threads the deadline through context for exactly this reason, and a handler that never calls context.is_active() gets none of the benefit of setting one.
  • Default retry policies are tuned for transient blips, not sustained saturation. A short, un-jittered backoff turns a struggling server into a target for more load at the worst possible moment.
  • A feature that adds 100ms of latency isn't free just because it's still under the timeout on a normal day. It's spending margin that was doing invisible work until the day there wasn't enough of it left.
  • Failing fast with RESOURCE_EXHAUSTED and excluding it from the retry policy is what actually let the system shed load. A slow success under pressure looks fine in isolation and is worse in aggregate than an immediate, honest rejection.

The semaphore's headroom of four threads below max_workers is deliberate: it gives the pool room to drain abandoned work without ever fully saturating, so the gauge has somewhere to fall before it hits the ceiling again.

Share this
← All Posts9 min read