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.
- name
grpc-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
- Public JSON APIs for browsers and third parties — use rest-api-design
- Async event streams between services — use kafka-streaming or event-driven-architecture
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.
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 asgoogle.protobuf.Timestamp; durations asgoogle.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
| Change | Wire-compatible? |
|---|---|
| Add a field with a new number | Yes |
Remove a field and reserved its number and name | Yes |
| Rename a field (same number) | Wire yes, JSON/ProtoJSON and source code no |
| Change a field’s type or number | No |
| Reuse a deleted field number | No: silent data corruption |
| Add an enum value | Yes, but old clients see unknown values; handle the default |
Move a field into or out of a oneof | No |
| Change package or service name | No: 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
| Pattern | Use |
|---|---|
| Server streaming | Watches, large result sets sent incrementally, progress |
| Client streaming | Uploads, batched ingestion |
| Bidirectional | Chat-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_robinwith a headless Kubernetes service (dns:///orders.default.svc.cluster.local:50051). Set serverMaxConnectionAgeso clients re-resolve and rebalance after scale-ups. - Retries: configure via service config (
retryPolicywithmaxAttempts,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
optionalor 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.
UNAVAILABLEspikes during deploys usually mean servers exit without graceful stop; callGracefulStop()/server.stop(grace)on SIGTERM.
Verification
buf lintandbuf breaking --against '.git#branch=main'pass.- In-process tests cover success, NOT_FOUND, INVALID_ARGUMENT with field violations, and deadline expiry.
grpcurlhealth check returnsSERVING; a call with a 1 ms deadline returnsDEADLINE_EXCEEDEDand the server log shows the work stopping.- 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
# 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.