TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Streaming, deadline, retry, and keepalive: When calls run long or fail

Status: Draft (2026-10-17), partially measured. Baseline versions: grpc/grpc-go v1.83.2, gRFC A6 Client Retries, grpc/grpc v1.84.0 doc/keepalive.md. Measurements were obtained by reading Prometheus andkubectl logsnode's etcd /metrics during the 03:00 KST hour on 2026-10-17. Experiments involving actual expansion of flow control windows, triggered retries, or keepalives were not performed (unmeasured; requirements include downloading Go modules to build the grpc-go example server and client).

Table of Contents

1. Intuition: Phone Calls and Radio

All four methods use the exact same HTTP/2 stream on the wire; the only differences are how many messages are exchangedand who signals the end first.

2. Diagram: Four Approaches

네 가지 호출 방식: 화살표 하나가 메시지 하나 단항 rpc Hit(Rally) returns (Score) C S 서버 스트리밍 returns (stream Flow) C S 클라이언트 스트리밍 rpc Upload(stream Chunk) C S 양방향 rpc Watch(stream) returns (stream) C S 진한 실선: 클라이언트→서버, 회색 점선: 서버→클라이언트
Diagram: Message flow for the four calling methods
Method Proto Declaration Examples Observed in Small Clusters
Unary rpc Range(RangeRequest) returns (RangeResponse) etcd Range/Txn, OTLP Export, all CRI calls
Server Streaming returns (stream …) Hubble Observer/GetFlows, Peer/Notify, etcd Maintenance/Snapshot
Client Streaming rpc …(stream …) Not found in metrics
Bidirectional Streaming rpc Watch(stream WatchRequest) returns (stream WatchResponse) etcd Watch/LeaseKeepAlive

(etcd declarations in rpc.proto, Hubble in observer.proto·peer.proto)

Streams are not free. Official performance guidelines recommend using them only when benefits are clear, noting that open streams cannot be migrated for load balancing and are difficult to debug (Performance Best Practices). This is why the concentration issues in Part 5 are more severe with streams.

3. Flow Control: The Receiver Sets the Pace

What happens if the server sends 10,000 messages per second in a server stream, but the client processes only 1,000? HTTP/2 prevents overload through the receiver opening a window indicating "you may send this much more" . There is one window per stream and one for the entire connection, with a default size of 65,535 bytes (RFC 9113). When the receiver consumes data, it reopens the window using WINDOW_UPDATE. If the window is 0, the sender's Send is blocked and waits. This is the backpressure mechanism in gRPC streams.

Two key features of grpc-go:

4. Propagation of Deadlines and Cancellations

deadline 은 시각이 아니라 남은 시간으로 건너간다 게이트웨이 점수 서비스 저장소 grpc-timeout 500m grpc-timeout 380m 남은 시간 막대 (0.5초 예산) 출발할 때 500ms 남음 120ms 처리 뒤 380ms 남음 저장소 도착 380ms 남음 시간이 다 되면: 클라이언트는 DEADLINE_EXCEEDED 를 받고, RST_STREAM(CANCEL) 이 서버로 가서 그쪽 ctx 도 취소된다. 서버가 다시 부른 다음 호출에도 같은 ctx 를 넘겨야 연쇄로 멈춘다.
Diagram: Propagation of deadlines as remaining time
  1. When a client invokes a call with a 0.5-second deadline, a grpc-timeout: 500m is sent (Part 3, Section 3.1).
  2. The server sets a timeout on its own context using that value. If it consumes 120ms and invokes the next service with the same context , grpc-go recalculates and sends the remaining 380ms as the new grpc-timeout . In effect, the timeout ticks down as it traverses the call chain.
  3. When the deadline expires, the client receives DEADLINE_EXCEEDED and sends a RST_STREAM(CANCEL) to the stream. Because the server-side context is cancelled, downstream calls invoked by that server using the inherited context are also stopped in a cascading fashion.

The trap lies in "with the same context" mentioned in step 2. In Go, if a server handler makes a downstream call using ctx instead of context.Background() , propagation is broken, causing downstream work to continue even after the upper layers have abandoned it. Since the specification dictates that requests without deadlines should run indefinitely (PROTOCOL-HTTP2.md Requests section), relying on defaults is dangerous.

4.1 Measurement: kubelet CRI Deadlines and Actual Latency

Kubelet's CRI client sets a timeout for every call. Defaults are runtimeRequestTimeout 2 minutes, RunPodSandbox twice that (4 minutes), and StopContainer is 2 minutes + termination grace period (remote_runtime.go:204, line 379). Conversely, PullImage has no deadline and only uses cancellation (remote_image.go of PullImage, context.WithCancel). This is because image pulls may legitimately take several minutes depending on size.

Calculated as a 6-hour histogram sum across 8 nodes.

histogram_quantile(0.99, sum by (operation_type, le) (rate(kubelet_runtime_operations_duration_seconds_bucket[6h])))
CRI Call p50 p99 Configured Deadline
pull_image 35.8s 116.6s None (Cancellation only)
stop_container 23.7s 47.2s 2 min + grace period
exec_sync (exec probe) 60ms 2.2s probe timeoutSeconds
run_podsandbox 365ms 1.19s 4 min
create_container 12ms 976ms 2 min
start_container 64ms 464ms 2 min
list_containers Under 5ms Under 5ms 2 min

stop_container 's p50 of 24 seconds is not due to a slow runtime, but rather because it waits for the app to finish within the grace period (usually 30s) after SIGTERM (estimated: pods with slow shutdowns are mixed in). Since the bucket boundaries are coarse at 0.005, 0.0125 ..., "Under 5ms" for list operations means it is within the first bucket. Whether there were CRI deadline exceeded errors in the last 6 hours was kubelet_runtime_operations_errors_total unconfirmed as the error type label was missing, making them indistinguishable.

4.2 Measurement: OTel Collector's 5-second deadline

Collector exports enforce a default 5-second deadline per request (opentelemetry-collector v0.159.0 timeout_sender.go:31). We counted export failures to Tempo by status code in the Collector logs (all logs since startup on 2026-09-29, reading kubectl logs ).

Date (UTC) DEADLINE_EXCEEDED UNAVAILABLE What happened then
2026-10-05 14:39~16:05 2,049 27 Tempo response delay (cause unconfirmed)
2026-10-09 18:25~18:27 34 32 Brief disconnection (cause unconfirmed)
2026-10-13 00:40~00:46 0 175 Tempo pod restart (terminated at 00:42, started at 00:44)

5. Retry and Hedging

gRPC allows libraries to retry per method via service config policies retryPolicy . For example, it looks like this (A6 format).

{"methodConfig": [{"name": [{"service": "tt.v1.Scoreboard"}],
  "retryPolicy": {"maxAttempts": 4, "initialBackoff": "0.1s", "maxBackoff": "1s",
                  "backoffMultiplier": 2, "retryableStatusCodes": ["UNAVAILABLE"]}}]}

The function that actually configures retries in grpc-go is csAttempt.shouldRetry . The rules are clear once you read it.

if a.transportStream != nil {
	if !a.transportStream.TrailersOnly() {
		return false, err
	}
	sps := a.transportStream.Trailer()["grpc-retry-pushback-ms"]
	...
}
rp := cs.methodConfig.RetryPolicy
if rp == nil || !rp.RetryableStatusCodes[code] {
	return false, err
}
if cs.retryThrottler.throttle() {
	return false, err
}
if cs.numRetries+1 >= rp.MaxAttempts {
	return false, fmt.Errorf("max retries exhausted: failed after %d attempts: %w", cs.numRetries+1, err)
}

Source: grpc/grpc-go v1.83.2 stream.go excerpt from lines 727–768, Apache-2.0

Hedging is a method of proactively sending the same request to multiple servers at regular intervals (hedgingDelay) without waiting for a response, using the fastest answer (A6). It reduces tail latency but should only be used for idempotent methods. In grpc-go 1.83.2, there are only TODO(hedging) comments (stream.go:634) and no code that reads hedgingPolicy from the service config. Essentially, it is not yet usable in Go (other language implementations have not been verified).

6. keepalive: Detecting dead connections quickly

TCP connections appear to stay alive for a while even if the peer disappears (e.g., power cutoff, eviction from NAT table). gRPC sends an HTTP/2 PING and drops the connection if there is no response.

Setting grpc-go default Rationale
Client Time(ping interval) Disabled (infinite). Minimum 10 seconds if enabled defaults.go:32, keepalive.go comment
Client Timeout 20 seconds Line 33 of the same file
Server Time 2 hours Line 38
Minimum ping interval allowed by the server (EnforcementPolicy.MinTime) 5 minutes Line 40
Allow ping when there are no active streams (PermitWithoutStream) Reject keepalive.go
Max violation count 2 times, GOAWAY on the third http2_server.go:881 maxPingStrikes
Maximum connection age (MaxConnectionAge) Infinite defaults.go:36

The most common incident is a mismatch in configurations between both sides. If the client sends a ping every 30 seconds while the server insists on its default (5 minutes), the server considers the pings "too frequent" and GOAWAY(ENHANCE_YOUR_CALM, "too_many_pings") closes the connection with (handlePing). The client sees this as RESOURCE_EXHAUSTED or UNAVAILABLE, and grpc-go clients subsequently double their ping intervals (keepalive.go comment).

6.1 Measurement: How apiserver and etcd are aligned

6.2 Measurement: How long do bidirectional streams live?

sum by (instance) (grpc_client_started_total{job="apiserver",grpc_method="Watch"})
  - sum by (instance) (grpc_client_handled_total{job="apiserver",grpc_method="Watch"})

There were 242, 239, and 239started yet unfinished Watch streams across three apiserver instances, respectively. At the same time, etcd on control node A etcd_debugging_mvcc_watch_stream_total 242, etcd_debugging_mvcc_watcher_total had 242, matching the client-side count exactly. Over the past hour, only 0.002 Watches ended per second (about 8 per hour), and the total Watch messages received across the three instances were about 181 per second. It appears that a single stream lives for days while flowing change events. Why there are precisely around 240 streams will be examined alongside the number of TCP connections in Part 6.

7. Limitations

8. Connecting to Small Clusters

Titi's note. When rallies get long, the referee yells "380ms remaining!" and passes it to the next referee. If you reset the clock (Background), you end up swinging alone even after the match is over!

References

All materials were accessed on 2026-10-17.

Sign in to like

Comments

No comments yet.

Sign in to leave a comment