Backend & APIs No. 087

gRPC Services skill for Claude Code

Safe proto evolution and calls that always have deadlines. Claude designs protobuf APIs with per-RPC request and response messages, reserved field numbers and versioned packages, then gates every change with buf lint and buf breaking. Server code propagates deadlines, returns precise status codes with google.rpc error details, retries only idempotent calls, and fixes the classic HTTP/2 problem of one client pinning all traffic to one pod.

  • namegrpc-services
  • size209 lines · ≈2,100 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs and operates gRPC services - protobuf API design following buf style, field numbering and reserved fields, backward-compatible evolution checked with buf breaking, code generation with buf.gen.yaml, unary and streaming RPCs, deadlines and cancellation, status codes and rich error details, retries, health checks, reflection, load balancing over HTTP/2, and testing with grpcurl. Use when writing or reviewing .proto files, adding gRPC endpoints, debugging DEADLINE_EXCEEDED or UNAVAILABLE errors, or setting up buf in CI.

Try asking

  • Design the proto for an inventory service with pagination and partial updates
  • We see DEADLINE_EXCEEDED everywhere after adding a new hop. Investigate
  • Set up buf lint and breaking-change checks for our protos in CI

Use it when

  • Writing or reviewing .proto files and service definitions
  • Adding unary or streaming gRPC endpoints
  • Debugging DEADLINE_EXCEEDED, UNAVAILABLE or uneven load across pods
  • Introducing buf generation and compatibility checks

Skip it when

How to check the result

buf lint and buf breaking pass against main, grpcurl health checks return SERVING, and a 1 ms deadline call returns DEADLINE_EXCEEDED with the server stopping its work.

The common mistake it prevents

Reusing a deleted field number instead of reserving it, which makes old clients silently decode new data into the wrong field.

~/.claude/skills/grpc-services/SKILL.md

--- name: grpc-services description: Designs and operates gRPC services - protobuf API design following buf style, field numbering and reserved fields, backward-compatible… license: MIT ---

A well-run gRPC API has protos that evolve without breaking deployed clients, every call carrying a deadline, failures mapped to the right status code with structured details, retries limited to idempotent methods, and load balancing that actually spreads long-lived HTTP/2 connections across servers.

Workflow

- [ ] 1. Find the proto layout, buf config (buf.yaml, buf.gen.yaml), languages, and runtime libs
- [ ] 2. Design or change messages and RPCs following the style rules below
- [ ] 3. buf format -w && buf lint && buf breaking against main
- [ ] 4. buf generate; implement server methods
- [ ] 5. Deadlines, cancellation, status codes, error details
- [ ] 6. Health, reflection, keepalive, load balancing, retries
- [ ] 7. Test with in-process server tests and grpcurl against a running instance

Proto design rules

syntax = "proto3";

package acme.orders.v1;                 // versioned package; directory acme/orders/v1/

import "google/protobuf/field_mask.proto";
import "google/protobuf/timestamp.proto";

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);
  rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
  rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse);
  rpc UpdateOrder(UpdateOrderRequest) returns (UpdateOrderResponse);
  rpc WatchOrders(WatchOrdersRequest) returns (stream WatchOrdersResponse);
}

message Order {
  string id = 1;
  string customer_id = 2;
  OrderStatus status = 3;
  int64 total_minor_units = 4;
  string currency_code = 5;
  google.protobuf.Timestamp create_time = 6;
  reserved 7;                           // deleted field: never reuse the number
  reserved "legacy_note";
}

enum OrderStatus {
  ORDER_STATUS_UNSPECIFIED = 0;         // zero value is always "unspecified"
  ORDER_STATUS_PENDING = 1;
  ORDER_STATUS_PAID = 2;
}

message ListOrdersRequest {
  int32 page_size = 1;                  // server caps it, e.g. 100
  string page_token = 2;                // opaque
  string filter = 3;
}
message ListOrdersResponse {
  repeated Order orders = 1;
  string next_page_token = 2;           // empty = last page
}

message UpdateOrderRequest {
  Order order = 1;
  google.protobuf.FieldMask update_mask = 2;   // only listed paths change
}
  • One request and one response message per RPC, named <Rpc>Request/<Rpc>Response, even if they wrap a single field today. This is what allows adding fields later.
  • Enum values prefixed with the enum name in UPPER_SNAKE; zero is _UNSPECIFIED.
  • Use optional (proto3) when you must distinguish “unset” from zero/empty for scalars.
  • Money as integer minor units plus currency (or google.type.Money); timestamps as google.protobuf.Timestamp; durations as google.protobuf.Duration.
  • Field numbers 1-15 encode in one byte; give them to frequent fields.
  • Field names lower_snake_case; generated code converts per language.

Compatibility

ChangeWire-compatible?
Add a field with a new numberYes
Remove a field and reserved its number and nameYes
Rename a field (same number)Wire yes, JSON/ProtoJSON and source code no
Change a field’s type or numberNo
Reuse a deleted field numberNo: silent data corruption
Add an enum valueYes, but old clients see unknown values; handle the default
Move a field into or out of a oneofNo
Change package or service nameNo: the method path changes
# buf.yaml (v2)
version: v2
modules:
  - path: proto
lint:
  use: [STANDARD]
breaking:
  use: [FILE]
buf format -w
buf lint
buf breaking --against '.git#branch=main'
buf generate                                 # uses buf.gen.yaml plugins

Run buf lint and buf breaking in CI on every PR that touches protos. A real breaking change requires a new package version (acme.orders.v2) served side by side, plus user confirmation.

Deadlines and cancellation

  • Every client call sets a deadline: there is no default, so a call without one can wait forever. Typical: 100 ms-2 s for interactive unary calls, longer for batch.
  • Servers propagate the incoming context to downstream calls so the remaining deadline shrinks across hops; never replace it with a fresh background context.
  • Check cancellation in long loops and streams (ctx.Err() in Go, context.is_active() in Python, call.isCancelled() in Java) and stop work.
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
resp, err := client.GetOrder(ctx, &ordersv1.GetOrderRequest{Id: id})
if status.Code(err) == codes.NotFound { ... }

Errors

Return a precise status code; read the mapping table in references/status-codes.md when choosing codes or deciding what clients may retry. Attach structured details (google.rpc.BadRequest with field violations, google.rpc.ErrorInfo with a machine-readable reason, google.rpc.RetryInfo) rather than encoding data in the message string. Never return OK with an error field in the response, and never leak stack traces in messages.

Streaming

PatternUse
Server streamingWatches, large result sets sent incrementally, progress
Client streamingUploads, batched ingestion
BidirectionalChat-like sessions, interactive protocols

Streams are long-lived: they pin to one backend, need keepalive to survive idle proxies, and must handle reconnection with a resume token. For large lists prefer paginated unary calls unless clients truly consume incrementally. Respect flow control: do not buffer unbounded messages when the receiver is slow.

Operations

  • Health: implement grpc.health.v1.Health; Kubernetes supports native gRPC probes (grpc: { port: 50051 } in the probe spec).
  • Reflection: enable in non-production (or behind auth) so grpcurl and Postman can discover services.
  • Load balancing: HTTP/2 multiplexes all calls over one connection, so an L4 load balancer sends everything from one client to one server. Use an L7 proxy that balances per request (Envoy, Linkerd, Istio, cloud L7 LBs with HTTP/2 backends), or client-side round_robin with a headless Kubernetes service (dns:///orders.default.svc.cluster.local:50051). Set server MaxConnectionAge so clients re-resolve and rebalance after scale-ups.
  • Retries: configure via service config (retryPolicy with maxAttempts, initialBackoff, retryableStatusCodes: ["UNAVAILABLE"]) only for idempotent methods. Hedging is for latency-sensitive idempotent reads only.
  • Keepalive: client keepalive pings must be allowed by server enforcement policy, or the server sends GOAWAY too_many_pings.
  • Message size: default max receive is 4 MiB; raise deliberately or stream.
  • Browsers: use gRPC-Web via Envoy, or ConnectRPC, which serves gRPC, gRPC-Web and a JSON-over-HTTP protocol from the same handlers.

Testing

grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 describe acme.orders.v1.OrderService
grpcurl -plaintext -d '{"id":"ord_123"}' localhost:50051 acme.orders.v1.OrderService/GetOrder
grpcurl -plaintext -d '{"service":""}' localhost:50051 grpc.health.v1.Health/Check

Unit-test servers in-process (Go bufconn, Java InProcessServerBuilder, Python grpc.server on a local port or grpc-testing) so tests exercise interceptors and serialization, not just method bodies.

Gotchas

  • proto3 scalar defaults are indistinguishable from unset without optional or wrappers.
  • Renaming a field breaks clients using JSON transcoding or ProtoJSON even though binary works.
  • Deadline exceeded on the client does not stop server work unless the server checks cancellation.
  • Generated code committed to the repo drifts from protos; either generate in CI and diff, or generate at build time.
  • UNAVAILABLE spikes during deploys usually mean servers exit without graceful stop; call GracefulStop()/server.stop(grace) on SIGTERM.

Verification

  1. buf lint and buf breaking --against '.git#branch=main' pass.
  2. In-process tests cover success, NOT_FOUND, INVALID_ARGUMENT with field violations, and deadline expiry.
  3. grpcurl health check returns SERVING; a call with a 1 ms deadline returns DEADLINE_EXCEEDED and the server log shows the work stopping.
  4. Under load across 3+ replicas, per-pod request rates are roughly even.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/status-codes.md 67 lines · 3.8 KB
# gRPC status codes: when to use and whether to retry

| Code | Use when | Client retry? | HTTP equivalent (transcoding) |
|---|---|---|---|
| `OK` (0) | Success | - | 200 |
| `CANCELLED` (1) | Caller cancelled | No | 499 |
| `UNKNOWN` (2) | Unmapped error (avoid returning deliberately) | No | 500 |
| `INVALID_ARGUMENT` (3) | Request is invalid regardless of system state (bad format, missing field) | No | 400 |
| `DEADLINE_EXCEEDED` (4) | Deadline expired before completion (operation may have completed) | Only if idempotent, with a new deadline budget | 504 |
| `NOT_FOUND` (5) | Entity does not exist (or caller may not know it exists) | No | 404 |
| `ALREADY_EXISTS` (6) | Create conflicts with an existing entity | No | 409 |
| `PERMISSION_DENIED` (7) | Authenticated caller lacks permission | No | 403 |
| `RESOURCE_EXHAUSTED` (8) | Quota or rate limit hit, or out of space | Yes, after backoff / `RetryInfo` delay | 429 |
| `FAILED_PRECONDITION` (9) | System not in required state (non-empty directory, wrong order state); fix state first | No (not until state changes) | 400 |
| `ABORTED` (10) | Concurrency conflict (transaction abort, optimistic lock, sequencer check) | Yes, retry the higher-level read-modify-write | 409 |
| `OUT_OF_RANGE` (11) | Past valid range (seek past end of file, page beyond end) | No | 400 |
| `UNIMPLEMENTED` (12) | Method not implemented or not enabled | No | 501 |
| `INTERNAL` (13) | Invariant broken, bug | No (usually) | 500 |
| `UNAVAILABLE` (14) | Transient: server down, overloaded, connection reset | Yes, with exponential backoff | 503 |
| `DATA_LOSS` (15) | Unrecoverable data loss or corruption | No | 500 |
| `UNAUTHENTICATED` (16) | Missing or invalid credentials | Only after refreshing credentials | 401 |

## Choosing between similar codes

- `FAILED_PRECONDITION` vs `ABORTED` vs `UNAVAILABLE`: retry the same call immediately
  is right → `UNAVAILABLE`; retry the read-modify-write cycle → `ABORTED`; do not retry
  until something changes → `FAILED_PRECONDITION`.
- `INVALID_ARGUMENT` vs `FAILED_PRECONDITION`: invalid no matter what the system state is
  → `INVALID_ARGUMENT`; valid input but the system state forbids it → `FAILED_PRECONDITION`.
- `NOT_FOUND` vs `PERMISSION_DENIED`: if revealing existence is itself a leak, return
  `NOT_FOUND` for both.
- `OUT_OF_RANGE` vs `INVALID_ARGUMENT`: `OUT_OF_RANGE` signals that iteration can stop
  (the range may become valid later); `INVALID_ARGUMENT` means the value is never valid.

## Codes the library generates

Clients see some codes without the server sending them: `UNAVAILABLE` for connection
failures, `DEADLINE_EXCEEDED` when the local deadline fires, `CANCELLED` on local
cancellation, `RESOURCE_EXHAUSTED` when a message exceeds max size, `UNIMPLEMENTED` for
unknown methods, `INTERNAL` for decode failures. Do not alert on client-side
`DEADLINE_EXCEEDED` without checking whether server latency actually rose.

## Rich error details (google.rpc)

| Detail message | Use for |
|---|---|
| `ErrorInfo` (reason, domain, metadata) | Stable machine-readable reason, e.g. `reason: "ORDER_ALREADY_PAID"` |
| `BadRequest` (field_violations) | Per-field validation errors with field path and description |
| `RetryInfo` (retry_delay) | How long to wait before retrying `RESOURCE_EXHAUSTED`/`UNAVAILABLE` |
| `QuotaFailure` | Which quota was exceeded |
| `PreconditionFailure` | Which precondition failed |
| `ResourceInfo` | Which resource was not found or denied |
| `LocalizedMessage` | User-facing translated message |

Go example:

```go
st := status.New(codes.InvalidArgument, "invalid order")
st, _ = st.WithDetails(&errdetails.BadRequest{
	FieldViolations: []*errdetails.BadRequest_FieldViolation{
		{Field: "items[0].quantity", Description: "must be at least 1"},
	},
})
return nil, st.Err()
```

Clients read details with `status.FromError(err)` then iterate `st.Details()`.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc