TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

gRPC over HTTP/2: Headers, 5-Byte Prefix, and Trailer Status Codes

Status: Draft (2026-10-17), partially measured. Reference versions: grpc/grpc v1.84.0 doc/PROTOCOL-HTTP2.md·doc/statuscodes.md, grpc/grpc-go v1.83.2. Status code distributions were obtained by only reading etcd from Prometheus and control node A in a small cluster during the 03:00 hour (KST) on 2026-10-17. /metrics Actual frames were not captured (unmeasured; prerequisites: grpcio or grpc-go example server, capture tools like Wireshark, etc.). Therefore, the bytes in sections 3, 4, and 5 are values constructed and calculated from specifications and code.

Table of Contents

1. Intuition: Envelope, Letter, and Result Stamp

It is easy to think of registered mail. The envelope(request headers) specifies the recipient department, task name, and processing deadline. Inside are multiple letters(messages), and each sheet has a label attached stating "this sheet is X bytes." The processing result is the result stamp(trailer) applied at the very end. The post office (HTTP) returns 200 indicating successful delivery, but whether the task succeeded must be checked via the stamp.

Thanks to this structure, gRPC can stream responses piece by piece and attach the status at the end. The status line in HTTP.1 must come at the very beginning, making this difficult.

2. Diagram

단항 호출 한 번이 HTTP/2 프레임으로 클라이언트 서버 HEADERS stream=1 :path /tt.v1.Scoreboard/Hit content-type: application/grpc · te: trailers DATA END_STREAM 00 00000015 + Rally 21바이트 HEADERS :status 200 · content-type DATA 00 + 길이 4바이트 + 응답 메시지 HEADERS END_STREAM 트레일러 grpc-status: 0 오류가 바로 나면 트레일러만 보낸다(Trailers-Only): :status 200 · grpc-status: 5 · grpc-message: %EB%9E%A0… HTTP 상태는 성공(200)이어도 호출의 성패는 grpc-status 가 정한다.
Figure: HTTP/2 frame sequence and Trailers-Only response for a single unary call

3. Request Headers

Summarizing the Call-Definition from the specification (PROTOCOL-HTTP2.md Requests section).

Header Value Reason
:method POST Always POST
:path /tt.v1.Scoreboard/Hit Method identification. Case-sensitive
:authority Target name Virtual host
te trailers Indication to filter out proxies that cannot handle trailers
content-type application/grpc(+proto·json…) Otherwise, server must reject with 415
grpc-timeout Integer up to 8 digits + unit (H, M, S, m, u, n) Remaining time. Infinite if absent
grpc-encoding, grpc-accept-encoding gzip etc. Message compression method
User metadata Lowercase name. Ends with -bin implies base64 grpc- Names starting with are reserved

In grpc-go, http2Client.createHeaderFields builds this list.

headerFields = append(headerFields, hpack.HeaderField{Name: ":method", Value: "POST"})
headerFields = append(headerFields, hpack.HeaderField{Name: ":scheme", Value: t.scheme})
headerFields = append(headerFields, hpack.HeaderField{Name: ":path", Value: callHdr.Method})
headerFields = append(headerFields, hpack.HeaderField{Name: ":authority", Value: callHdr.Host})
headerFields = append(headerFields, hpack.HeaderField{Name: "content-type", Value: grpcutil.ContentType(callHdr.ContentSubtype)})
headerFields = append(headerFields, hpack.HeaderField{Name: "user-agent", Value: t.userAgent})
headerFields = append(headerFields, hpack.HeaderField{Name: "te", Value: "trailers"})
...
if dl, ok := ctx.Deadline(); ok {
	timeout := time.Until(dl)
	if timeout <= 0 {
		return nil, status.Error(codes.DeadlineExceeded, context.DeadlineExceeded.Error())
	}
	headerFields = append(headerFields, hpack.HeaderField{Name: "grpc-timeout", Value: grpcutil.EncodeDuration(timeout)})
}

Source: grpc/grpc-go v1.83.2 internal/transport/http2_client.go excerpt from lines 578–614, Apache-2.0

3.1 Choosing the Unit for grpc-timeout

EncodeDuration starts from nanoseconds and selects the smallest unit that fits within 8 digitsand rounds up. Values calculated by porting the same rule to Python (pb_by_hand.py).

Remaining Time grpc-timeout
50ms 50000000n
4.999987s 4999987u
2 minutes (kubelet CRI default) 120000m
4 minutes (RunPodSandbox) 240000m
1 hour 3600000m

Even if a 5-second deadline is set, a few microseconds elapse by the moment the header is created, so it goes out as 5S rather than 4999987u . The server reads it back as decodeTimeout and sets a timeout in its own context. If the value is malformed, it is rejected with INTERNAL (aroundhttp2_server.go:453 ).

4. 5-Byte Message Prefix

What is carried in DATA frames is a sequence of Length-Prefixed-Message instances: 1 compression flag byte (0 or 1) + 4 length bytes (big-endian) + message.

What Bytes (calculated)
Rally 21 bytes from Part 2 00 00 00 00 15 + 08 96 01 … 28 03 = 26 bytes
Health check request (empty message) 00 00 00 00 00
Health check response SERVING 00 00 00 00 02 08 01

The receiving side in grpc-go works like this.

func (p *parser) recvMsg(maxReceiveMessageSize int) (payloadFormat, mem.BufferSlice, error) {
	err := p.r.ReadMessageHeader(p.header[:])
	if err != nil {
		return 0, nil, err
	}
	pf := payloadFormat(p.header[0])
	length := binary.BigEndian.Uint32(p.header[1:])
	...
	if int(length) > maxReceiveMessageSize {
		return 0, nil, status.Errorf(codes.ResourceExhausted, "grpc: received message larger than max (%d vs. %d)", length, maxReceiveMessageSize)
	}
	data, err := p.r.Read(int(length))

Source: grpc/grpc-go v1.83.2 rpc_util.go excerpt from lines 785–800, Apache-2.0

5. Response and Trailers

The response consists of headers containing :status 200 and content-type , messages, and trailers. The trailer contains grpc-status(decimal) and optionally grpc-message, grpc-status-details-bin(containing google.rpc.Statusin base64). Even for success (0), the status is always carried in the trailer.

grpc-message encodes UTF-8 with percent-encoding. It leaves only spaces and tildes (excluding% ) as is and replaces the rest with %XX . This value is calculated by porting grpc-go's encodeGrpcMessage rules.

message wire's grpc-message
rally not found rally not found
랠리 없음 %EB%9E%A0%EB%A6%AC %EC%97%86%EC%9D%8C
100% done 100%25 done

If a response with a non-200 HTTP status is received (such as 502 generated by an intermediate proxy), the client fabricatesthe status. grpc-go's HTTPStatusConvTab mapping is 400→INTERNAL, 401→UNAUTHENTICATED, 403→PERMISSION_DENIED, 404→UNIMPLEMENTED, and 429·502·503·504→UNAVAILABLE. If you see unmotivated UNAVAILABLE errors behind a proxy, it means they might actually have been HTTP 503. Other HTTP errors become UNKNOWN.

Conversely, if a stream is cut off with RST_STREAM , HTTP/2 error codes are translated into statuses: REFUSED_STREAM→UNAVAILABLE (retryable as it occurs before processing), CANCEL→CANCELLED, ENHANCE_YOUR_CALM→RESOURCE_EXHAUSTED (keepalive ping violation from Part 4), and most others are INTERNAL (http2ErrConvTab, specification Errors section).

6. The 17 Status Codes

statuscodes.md is summarized from an operational perspective. The "Library" column indicates cases where the gRPC library can generate them itself.

Number Name Meaning Example generated by the library
0 OK Success —
1 CANCELLED Usually cancelled by the caller Client cancellation (both sides)
2 UNKNOWN Unknown error Server handler returns a stateless error, status interpretation failure
3 INVALID_ARGUMENT Request itself is invalid None in documentation (see Part 8)
4 DEADLINE_EXCEEDED Deadline exceeded. May have succeeded on the server Both sides
5 NOT_FOUND Target not found None
6 ALREADY_EXISTS Already exists None
7 PERMISSION_DENIED Permission denied —
8 RESOURCE_EXHAUSTED Quota or resource exhausted Message size exceeded, flow control limit
9 FAILED_PRECONDITION State mismatch, do not retry until fixed None
10 ABORTED Concurrency conflict, retry at a higher level None
11 OUT_OF_RANGE Out of range (e.g., beyond end of file) None
12 UNIMPLEMENTED Method missing or unsupported Method missing on server, unsupported compression
13 INTERNAL Invariant broken Deserialization failure, flow control violation
14 UNAVAILABLE Transient. Retry after backoff Server shutting down, connection dropped, keepalive timeout
15 DATA_LOSS Unrecoverable loss None
16 UNAUTHENTICATED Authentication info missing or incorrect Credential failure

The documentation states that INVALID_ARGUMENT, NOT_FOUND, ALREADY_EXISTS, FAILED_PRECONDITION, ABORTED, OUT_OF_RANGE, and DATA_LOSS are never generated by the library. Therefore, seeing these codes can be interpreted as "the app intentionally returned them." However, grpc-go sends requests with an invalid content-type along with HTTP 415 carrying grpc-status: 3(INVALID_ARGUMENT) (http2_server.go:504). Because this point contradicts the documentation, I noted it as a contribution candidate in Part 8.

7. Measurement: Counting the same calls from both sides

On control node A, kube-apiserver connects only to the local etcd (127.0.0.1:2379). Since both processes started at the same second (2026-08-19 07:42:56 UTC), their cumulative counters cover the same period. The client side is apiserver's grpc_client_handled_total(Prometheus), and the server side is etcd's grpc_server_handled_total(on the node, curl -s http://127.0.0.1:2381/metrics, reads only). Only non-OK results are collected.

sum by (instance, grpc_code, grpc_method) (grpc_client_handled_total{job="apiserver", grpc_code!="OK"})
Method Code apiserver (client) etcd (server)
Watch (bidi) CANCELLED 6,982 6,980
Range OUT_OF_RANGE 94 94
Range UNAVAILABLE 1 1
Range CANCELLED 7 3
Range DEADLINE_EXCEEDED 1 0
Txn UNAVAILABLE 9 14
Txn CANCELLED 12 0
Txn DEADLINE_EXCEEDED 4 0
Txn UNKNOWN 0 4

During the same period, OK calls were 83.33 million for Range and 38.67 million for Txn on the server side, making the error ratio very small. More interesting than the ratio is how the names mismatch.

To sum it up in one line, "status codes are recorded differently depending on who made the determination". Use caller-side metrics for alerts and place both sides side-by-side for root cause analysis.

One more bonus. If you run hubble list nodes inside the Cilium agent, you get rpc error: code = Unimplemented desc = GetNodes not implemented . The Hubble server for a single node deliberately does not implement GetNodes (local_observer.go:250), meaning you should direct that question to hubble-relay, which is aware of multiple nodes. This is an example where UNIMPLEMENTED is used not only when "the method does not exist at all" but also when "it is not supported here."

8. Limitations

9. Connection with Small Clusters

If you are creating apiserver-to-etcd alerts, do not use grpc_code!="OK" as-is; instead, you need to check Watch CANCELLED and OUT_OF_RANGE separately. The former is normal, while the latter indicates that the watch requested a revision that is too old, giving you a reason to check the compaction interval and watch cache.

A word from TT. Even if the post office says "Delivered (200)," you shouldn't let your guard down. Be sure to check the final stamp at the end of the letter (grpc-status)!

References

All materials were accessed on 2026-10-17.

Sign in to like

Comments

No comments yet.

Sign in to leave a comment