Spring gRPC on Spring Boot 4: four call types, exception mapping, and a symptom-first troubleshooting suite
Companion code for the ankurm.com guide. Verified on Spring Boot 4.1.0, spring-grpc 1.1.0,
grpc-java 1.80.0, protobuf-java 4.34.2, JDK 25.0.3. results-full.txt is unedited mvn test
output: 11 tests, 0 failures. No installs needed - protoc and the gRPC codegen plugin resolve
as Maven artifacts, and every test uses the in-process transport.
_01_basics all four call types and how their failure modes differ: iterator semantics on
server streaming, the half-close that client streaming hangs without, and the
independence of the two streams in bidi.
_02_troubleshooting
four failures reproduced then fixed - the 4 MB message limit and which side
enforces it, the absent default deadline, cancellation that never interrupts a
thread, and errors that arrive as UNKNOWN with no description.
_03_exceptions @GrpcAdvice / @GrpcExceptionHandler: domain exception to NOT_FOUND with
trailers, validation to INVALID_ARGUMENT, and a catch-all that returns a
deliberate INTERNAL without leaking the original message across the boundary.
docs/01 symptom-first troubleshooting index, each entry marked [tested] or [documented]
docs/02 complete spring.grpc.* property reference, and the split between the Boot 4.1
integration (org.springframework.boot, version 4.1.0, owns properties and
auto-configuration) and the Spring gRPC project (org.springframework.grpc,
version 1.1.0, owns the programming model). The older standalone
spring-grpc-spring-boot-starter stops at 1.0.3 and is what most search results
describe.
Findings worth the commit message
- The in-process transport CANNOT enforce message size limits: it passes messages by
reference and never serialises them. A 4 MB + 1 KB message goes through cleanly in tests
and fails in production with RESOURCE_EXHAUSTED. The test asserts this rather than
pretending otherwise. Same blind spot covers compression, TLS, keepalive and LB.
- Two property names cost real time while writing this:
spring.grpc.server.inprocess.name (not in-process) and
spring.grpc.client.channel.<n>.target (singular channel, and target not address).
The second failure surfaces as UnknownHostException on the channel NAME.
- gRPC still has no default deadline, and cancellation only sets a Context flag.
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
target/
|
||||
*.class
|
||||
t1.txt
|
||||
t2.txt
|
||||
gen.txt
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.DS_Store
|
||||
|
||||
# results-full.txt IS committed on purpose -- it is the evidence for the blog post
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ankur Mhatre
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Spring gRPC with Spring Boot 4
|
||||
|
||||
Companion repository for **[Spring gRPC with Spring Boot 4](https://ankurm.com/spring-grpc-spring-boot-4/)**
|
||||
on [ankurm.com](https://ankurm.com).
|
||||
|
||||
The post covers the concepts. This repository adds the part that is hard to find written down: a
|
||||
symptom-first troubleshooting guide, with the failures reproduced as tests wherever a test can
|
||||
honestly reproduce them.
|
||||
|
||||
Verified against **Spring Boot 4.1.0**, **spring-grpc 1.1.0**, **grpc-java 1.80.0**,
|
||||
**protobuf-java 4.34.2**, **JDK 25.0.3**. Full output in [`results-full.txt`](results-full.txt).
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
Nothing to install — `protoc` and the gRPC codegen plugin are resolved as Maven artifacts, and every
|
||||
test runs over the in-process transport, so no port is bound.
|
||||
|
||||
```console
|
||||
git clone https://ankurm.com/git.app/asmhatre/spring-grpc-boot4.git
|
||||
cd spring-grpc-boot4
|
||||
mvn test
|
||||
```
|
||||
|
||||
8 tests, a few seconds.
|
||||
|
||||
---
|
||||
|
||||
## What is here
|
||||
|
||||
| Test | Covers |
|
||||
|---|---|
|
||||
| [`FourCallTypesTest`](src/test/java/com/ankurm/grpc/_01_basics/FourCallTypesTest.java) | Unary, server streaming, client streaming, bidirectional — and how their failure modes differ |
|
||||
| [`HardToDiagnoseTest`](src/test/java/com/ankurm/grpc/_02_troubleshooting/HardToDiagnoseTest.java) | Four failures reproduced and fixed: message size limits, missing deadlines, unobserved cancellation, useless error statuses |
|
||||
| [`ExceptionMappingTest`](src/test/java/com/ankurm/grpc/_03_exceptions/ExceptionMappingTest.java) | `@GrpcAdvice` / `@GrpcExceptionHandler` — domain exception → `NOT_FOUND`, validation → `INVALID_ARGUMENT`, catch-all → `INTERNAL` without leaking detail |
|
||||
|
||||
| Document | Covers |
|
||||
|---|---|
|
||||
| [Troubleshooting](docs/01-troubleshooting.md) | Symptom-first index: every entry starts from the error message you actually see |
|
||||
| [Properties and versions](docs/02-properties-and-versions.md) | Complete `spring.grpc.*` reference, and which of the two projects owns what |
|
||||
|
||||
### Exception mapping
|
||||
|
||||
[`OrderExceptionAdvice`](src/main/java/com/ankurm/grpc/orders/OrderExceptionAdvice.java) is the gRPC
|
||||
analogue of `@RestControllerAdvice`. The service throws plain domain exceptions and never mentions
|
||||
`Status`:
|
||||
|
||||
```java
|
||||
@GrpcAdvice
|
||||
public class OrderExceptionAdvice {
|
||||
|
||||
@GrpcExceptionHandler(OrderNotFoundException.class)
|
||||
public StatusException handleNotFound(OrderNotFoundException ex) {
|
||||
Metadata trailers = new Metadata();
|
||||
trailers.put(REASON_KEY, "ORDER_NOT_FOUND");
|
||||
return Status.NOT_FOUND.withDescription(ex.getMessage()).asException(trailers);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Without it, every domain failure arrives as `UNKNOWN` with a **null description** — no code to
|
||||
branch on and no message to read.
|
||||
|
||||
The service itself ([`OrderServiceImpl`](src/main/java/com/ankurm/grpc/orders/OrderServiceImpl.java))
|
||||
is heavily commented and is where the correct patterns live — cancellation checks, half-close
|
||||
handling, `StreamObserver` thread-safety, structured errors.
|
||||
|
||||
---
|
||||
|
||||
## Five findings
|
||||
|
||||
1. **gRPC is a first-class Boot starter now, and "Spring gRPC" means two things.**
|
||||
`org.springframework.boot:spring-boot-starter-grpc-server` (version **4.1.0**) owns
|
||||
auto-configuration and every `spring.grpc.*` property; `org.springframework.grpc:spring-grpc-core`
|
||||
(version **1.1.0**) owns the programming model — `@GrpcService`, `@GrpcAdvice`,
|
||||
`GrpcChannelFactory`. The older standalone `spring-grpc-spring-boot-starter` stops at 1.0.3 and
|
||||
is what most search results describe. See [docs/02](docs/02-properties-and-versions.md).
|
||||
2. **The in-process transport cannot enforce message size limits.** It passes messages by reference
|
||||
and never serialises them, so a 4 MB + 1 KB message sails through — and fails in production with
|
||||
`RESOURCE_EXHAUSTED`. Asserted in the test suite. The same blind spot covers compression, TLS,
|
||||
keepalive and load balancing.
|
||||
3. **Two property names that cost real time.** `spring.grpc.server.inprocess.name` (not
|
||||
`in-process`) and `spring.grpc.client.channel.<n>.target` (singular `channel`, and `target` not
|
||||
`address`). Getting the second wrong yields `UnknownHostException` on the channel *name*, which
|
||||
sends you to look at DNS.
|
||||
4. **gRPC has no default deadline.** A call without one waits forever. This is the single most
|
||||
common cause of a gRPC service that silently stops responding.
|
||||
5. **Cancellation does not interrupt your thread.** It sets a `Context` flag. A server that never
|
||||
checks it keeps working for a client that left ten minutes ago.
|
||||
|
||||
---
|
||||
|
||||
## Reference machine
|
||||
|
||||
AMD Ryzen 5 5600U, Windows 11, `java 25.0.3+9-LTS-195`, Maven 3.9.9.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
188
docs/01-troubleshooting.md
Normal file
188
docs/01-troubleshooting.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Troubleshooting Spring gRPC on Boot 4
|
||||
|
||||
Symptom-first. Each entry is something whose error message points somewhere other than its cause.
|
||||
Entries marked **[tested]** are reproduced in
|
||||
[`HardToDiagnoseTest`](../src/test/java/com/ankurm/grpc/_02_troubleshooting/HardToDiagnoseTest.java);
|
||||
entries marked **[documented]** need a real network and are stated rather than asserted, because a
|
||||
test claiming to prove them over in-process transport would be lying.
|
||||
|
||||
---
|
||||
|
||||
## `UNAVAILABLE: Unable to resolve host <name>` — **[tested]**
|
||||
|
||||
**Cause, 90% of the time: a typo in a property name**, not a networking problem.
|
||||
|
||||
An unmatched channel name is passed straight to the DNS resolver as a target. Boot does not warn
|
||||
that the channel is unconfigured. The exact names, both of which are easy to get wrong:
|
||||
|
||||
```properties
|
||||
spring.grpc.server.inprocess.name=orders-test # NOT "in-process"
|
||||
spring.grpc.client.channel.orders.target=static://... # SINGULAR "channel"; "target", NOT "address"
|
||||
```
|
||||
|
||||
Also check `spring.grpc.client.inprocess.enabled=true` if you are using the in-process transport
|
||||
from the client side.
|
||||
|
||||
## `IllegalStateException: No grpc channel factory found that supports target : <name>`
|
||||
|
||||
`GrpcChannelFactory` is a composite that asks each registered factory `supports(target)` **before**
|
||||
named-channel indirection is applied. Passing a bare logical name to `createChannel()` can therefore
|
||||
fail even though the name is correctly configured. In application code, inject the stub (via
|
||||
`@ImportGrpcClients`) and let Boot resolve the target; in tests, pass the full target.
|
||||
|
||||
## `RESOURCE_EXHAUSTED: gRPC message exceeds maximum size 4194304` — **[tested]**
|
||||
|
||||
The default limit is **4 MB, per message, on the receiver**, and the two sides are configured
|
||||
separately.
|
||||
|
||||
```properties
|
||||
# large RESPONSE -> the CLIENT is the receiver
|
||||
spring.grpc.client.channel.<name>.inbound.message.max-size=16MB
|
||||
# large REQUEST -> the SERVER is the receiver
|
||||
spring.grpc.server.inbound.message.max-size=16MB
|
||||
```
|
||||
|
||||
Per call: `stub.withMaxInboundMessageSize(16 * 1024 * 1024)`.
|
||||
|
||||
Half of all "I raised the limit and it still fails" reports are the asymmetry: raising it on the
|
||||
server does nothing for a large response.
|
||||
|
||||
> **The testing trap.** The in-process transport passes messages **by reference and never
|
||||
> serialises them**, so it *cannot* enforce size limits. A payload-size regression passes every
|
||||
> in-process test you have and fails in production. The same applies to compression and anything
|
||||
> else depending on the wire format. This is demonstrated — the test asserts that a 4 MB + 1 KB
|
||||
> message goes through in-process without complaint.
|
||||
|
||||
Prefer streaming to large unary messages: the limit is per message, so 1,000 small messages are
|
||||
fine. Do not raise the limit globally — a large limit turns a malformed request into an OOM.
|
||||
|
||||
## Calls that hang forever — **[tested]**
|
||||
|
||||
**gRPC has no default deadline.** A call without one waits indefinitely. Nothing warns you, and in
|
||||
testing the server always responds quickly, so it surfaces the day something downstream is slow —
|
||||
as a thread pool that fills up with no errors in the log.
|
||||
|
||||
```java
|
||||
stub.withDeadlineAfter(2, TimeUnit.SECONDS).getOrder(request);
|
||||
```
|
||||
|
||||
Per channel: `spring.grpc.client.channel.<name>.default.deadline=2s`, or register a
|
||||
`DefaultDeadlineSetupClientInterceptor`.
|
||||
|
||||
Deadlines are **absolute and propagate**: if A calls B with 2s remaining, B sees 2s, not a fresh 2s.
|
||||
Set the deadline at the edge; do not re-set it at every hop, or a deep chain multiplies its budget.
|
||||
|
||||
## The server keeps working after the client gave up — **[tested]**
|
||||
|
||||
gRPC **does not interrupt your thread** on cancellation. It sets a flag on the `Context`.
|
||||
|
||||
```java
|
||||
for (Item item : items) {
|
||||
if (Context.current().isCancelled()) return; // no onCompleted/onError -- stream is closed
|
||||
responseObserver.onNext(convert(item));
|
||||
}
|
||||
```
|
||||
|
||||
For blocking work handed to another thread, propagate with `Context.current().wrap(runnable)` —
|
||||
otherwise the flag is invisible there. Calling `onNext`/`onCompleted` after cancellation throws
|
||||
`IllegalStateException`.
|
||||
|
||||
## Every error is `UNKNOWN` with no message — **[tested]**
|
||||
|
||||
Any exception escaping a handler becomes `UNKNOWN` with a **null description** — deliberately, since
|
||||
leaking exception text across a service boundary is an information-disclosure risk.
|
||||
|
||||
```java
|
||||
Metadata trailers = new Metadata();
|
||||
trailers.put(REASON_KEY, "ORDER_LOCKED");
|
||||
responseObserver.onError(new StatusRuntimeException(
|
||||
Status.FAILED_PRECONDITION.withDescription("order is locked"), trailers));
|
||||
```
|
||||
|
||||
Choose the code carefully — it is the contract that retry policies, circuit breakers and dashboards
|
||||
key off:
|
||||
|
||||
| Do **not** retry | Retry may help |
|
||||
|---|---|
|
||||
| `INVALID_ARGUMENT`, `NOT_FOUND`, `ALREADY_EXISTS`, `FAILED_PRECONDITION`, `PERMISSION_DENIED`, `UNAUTHENTICATED` | `UNAVAILABLE`, `DEADLINE_EXCEEDED`, `RESOURCE_EXHAUSTED`, `ABORTED` |
|
||||
|
||||
Getting this wrong makes non-retryable failures look retryable and turns one bad request into a
|
||||
storm.
|
||||
|
||||
## `IllegalStateException: call already closed` / `half-closed`
|
||||
|
||||
You called `onNext` or `onCompleted` after the stream ended. Usual causes:
|
||||
|
||||
- responding after cancellation or deadline expiry (check `Context.current().isCancelled()` first);
|
||||
- calling `onCompleted()` twice;
|
||||
- calling `onError()` and then `onCompleted()` — `onError` is terminal;
|
||||
- two threads writing to one `StreamObserver`. **`StreamObserver` is not thread-safe.** Concurrent
|
||||
`onNext` corrupts the stream, and the symptom is usually a deserialization error on the *far*
|
||||
side, pointing nowhere near the bug.
|
||||
|
||||
## A client-streaming call never completes
|
||||
|
||||
You forgot the half-close. `requestObserver.onCompleted()` is what tells the server "no more
|
||||
requests"; without it the server's `onCompleted` never fires and the call hangs until the deadline —
|
||||
or forever, if you did not set one.
|
||||
|
||||
## Retries do nothing — **[documented]**
|
||||
|
||||
gRPC retries are configured through the **service config**, not a client API, and are off by
|
||||
default:
|
||||
|
||||
```properties
|
||||
spring.grpc.client.channel.orders.service-config.methodConfig[0].retryPolicy.maxAttempts=4
|
||||
spring.grpc.client.channel.orders.service-config.methodConfig[0].retryPolicy.initialBackoff=0.5s
|
||||
spring.grpc.client.channel.orders.service-config.methodConfig[0].retryPolicy.retryableStatusCodes[0]=UNAVAILABLE
|
||||
```
|
||||
|
||||
Three reasons they silently do nothing:
|
||||
|
||||
1. The status you are failing with is not in `retryableStatusCodes`.
|
||||
2. The response was already partially delivered — gRPC will not retry a committed stream.
|
||||
3. Retry is disabled on the channel (`enableRetry()` / the corresponding property).
|
||||
|
||||
Only make idempotent methods retryable. gRPC has no idea whether your RPC is safe to repeat.
|
||||
|
||||
## Connections drop every N minutes through a proxy — **[documented]**
|
||||
|
||||
Load balancers and ingress controllers close idle HTTP/2 connections. gRPC reuses one long-lived
|
||||
connection, so an idle stream is not an idle *connection* — the proxy disagrees.
|
||||
|
||||
```properties
|
||||
spring.grpc.client.channel.<name>.keepalive.time=30s
|
||||
spring.grpc.client.channel.<name>.keepalive.timeout=5s
|
||||
spring.grpc.server.keepalive.connection.max-idle-time=...
|
||||
spring.grpc.server.keepalive.connection.max-age=...
|
||||
```
|
||||
|
||||
Keep the client's keepalive interval **shorter** than the proxy's idle timeout. Note the server can
|
||||
reject keepalives that are too frequent (`ENHANCE_YOUR_CALM` / `too_many_pings`), which looks like a
|
||||
random disconnect — so both ends need to agree.
|
||||
|
||||
## All traffic goes to one backend — **[documented]**
|
||||
|
||||
The default load-balancing policy is `pick_first`, not `round_robin`. With a DNS target resolving to
|
||||
several addresses, a gRPC client picks one connection and keeps it. This is correct behaviour and
|
||||
almost never what people expect behind a headless Kubernetes service.
|
||||
|
||||
Set `round_robin` via service config, and remember that DNS re-resolution happens on connection
|
||||
failure, not on a timer — so scaling up does not redistribute existing connections.
|
||||
|
||||
---
|
||||
|
||||
## Test transport limitations, collected
|
||||
|
||||
The in-process transport is excellent for testing protocol behaviour and misleading for anything
|
||||
wire-related. It **does** exercise interceptors, metadata, status codes, deadlines, cancellation and
|
||||
flow control. It does **not** exercise:
|
||||
|
||||
- message size limits (no serialization — proven in the test suite),
|
||||
- compression,
|
||||
- TLS and credentials negotiation,
|
||||
- keepalive, GOAWAY, idle timeouts,
|
||||
- name resolution and load balancing,
|
||||
- HTTP/2 framing and header limits.
|
||||
|
||||
Anything in that second list needs a real port, and belongs in a smaller, slower test tier.
|
||||
144
docs/02-properties-and-versions.md
Normal file
144
docs/02-properties-and-versions.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Properties reference, and which project owns what
|
||||
|
||||
Two things that cause disproportionate confusion: the exact property names, and the fact that
|
||||
**"Spring gRPC" now means two different things with two different version numbers**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Boot 4.1 integration vs. the Spring gRPC project
|
||||
|
||||
These are separate artifacts with separate versions, and both are on your classpath.
|
||||
|
||||
| | **Spring Boot gRPC integration** | **Spring gRPC** |
|
||||
|---|---|---|
|
||||
| Group | `org.springframework.boot` | `org.springframework.grpc` |
|
||||
| Artifacts | `spring-boot-starter-grpc-server`, `spring-boot-starter-grpc-client`, `spring-boot-grpc-server`, `spring-boot-grpc-client`, `spring-boot-grpc-test` | `spring-grpc-core` |
|
||||
| Version here | **4.1.0** (the Boot version) | **1.1.0** |
|
||||
| Owns | auto-configuration, `spring.grpc.*` properties, starters, actuator/health, observation wiring | the programming model: `@GrpcService`, `@GrpcAdvice`, `@GrpcExceptionHandler`, `GrpcChannelFactory`, `@ImportGrpcClients`, interceptor infrastructure |
|
||||
| Managed by | the Boot BOM directly | the Boot BOM, via the `spring-grpc.version` property |
|
||||
|
||||
Practical consequences:
|
||||
|
||||
- **Property names (`spring.grpc.*`) come from Boot 4.1**, defined in `GrpcServerProperties` /
|
||||
`GrpcClientProperties` under `org.springframework.boot.grpc.*.autoconfigure`. Version them against
|
||||
your Boot version.
|
||||
- **Annotations and interfaces come from Spring gRPC 1.1.0**, in `org.springframework.grpc.*`.
|
||||
Version them against `spring-grpc.version`.
|
||||
- Boot 4.1 also pins `grpc-java` (**1.80.0** here) and `protobuf-java` (**4.34.2**). Both are
|
||||
older than the newest releases on Maven Central — that is deliberate, and overriding them
|
||||
independently is how you get `NoSuchMethodError` between grpc-java and its Netty shading.
|
||||
|
||||
### The older path, and why you will see it in search results
|
||||
|
||||
Before Boot 4, the community project shipped its own starter:
|
||||
|
||||
```xml
|
||||
<!-- Boot 3 era -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.grpc</groupId>
|
||||
<artifactId>spring-grpc-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
That artifact stops at **1.0.3** and is not what Boot 4 uses. On Boot 4 use the Boot starters and
|
||||
let the BOM manage everything:
|
||||
|
||||
```xml
|
||||
<!-- Boot 4 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-grpc-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-grpc-client</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Most blog posts and Stack Overflow answers you find will be describing the older artifact. The
|
||||
programming model is largely the same; the dependency coordinates and the property names are not.
|
||||
|
||||
---
|
||||
|
||||
## 2. Server properties — exact names
|
||||
|
||||
Read from `spring-boot-grpc-server-4.1.0.jar`'s configuration metadata. This is the complete list.
|
||||
|
||||
| Property | Notes |
|
||||
|---|---|
|
||||
| `spring.grpc.server.enabled` | |
|
||||
| `spring.grpc.server.port` | `-1` disables the network server (in-process only); `0` picks an ephemeral port |
|
||||
| `spring.grpc.server.address` | |
|
||||
| `spring.grpc.server.inprocess.name` | **`inprocess`, one word — not `in-process`** |
|
||||
| `spring.grpc.server.inbound.message.max-size` | the 4 MB default, for incoming **requests** |
|
||||
| `spring.grpc.server.inbound.metadata.max-size` | header size limit |
|
||||
| `spring.grpc.server.shutdown.grace-period` | |
|
||||
| `spring.grpc.server.keepalive.time` | |
|
||||
| `spring.grpc.server.keepalive.timeout` | |
|
||||
| `spring.grpc.server.keepalive.permit.time` | minimum client ping interval the server tolerates |
|
||||
| `spring.grpc.server.keepalive.permit.without-calls` | |
|
||||
| `spring.grpc.server.keepalive.connection.max-age` | |
|
||||
| `spring.grpc.server.keepalive.connection.max-idle-time` | |
|
||||
| `spring.grpc.server.keepalive.connection.grace-period` | |
|
||||
| `spring.grpc.server.ssl.enabled` / `.bundle` / `.client-auth` / `.secure` | uses Boot SSL bundles |
|
||||
| `spring.grpc.server.health.enabled` | standard gRPC health service |
|
||||
| `spring.grpc.server.health.service` | |
|
||||
| `spring.grpc.server.health.include-overall-health` | |
|
||||
| `spring.grpc.server.health.status.mapping` / `.order` | actuator status → gRPC serving status |
|
||||
| `spring.grpc.server.health.schedule.enabled` / `.delay` / `.period` | |
|
||||
| `spring.grpc.server.health.services.validate-membership` | |
|
||||
| `spring.grpc.server.reflection.enabled` | needed for `grpcurl` without local `.proto` files |
|
||||
| `spring.grpc.server.observation.enabled` | Micrometer observations |
|
||||
| `spring.grpc.server.netty.transport` | |
|
||||
| `spring.grpc.server.netty.domain-socket-path` | |
|
||||
| `spring.grpc.server.servlet.enabled` / `.validate-http2` | gRPC over the servlet container |
|
||||
| `spring.grpc.server.security.csrf.enabled` | |
|
||||
| `spring.grpc.server.factory.enabled` | |
|
||||
|
||||
## 3. Client properties — exact names
|
||||
|
||||
The client side is a **map keyed by channel name**:
|
||||
|
||||
```
|
||||
spring.grpc.client.channel.<name>.<property>
|
||||
```
|
||||
|
||||
**`channel`, singular.** `channels` is wrong and produces no error — see below.
|
||||
|
||||
| Property | Notes |
|
||||
|---|---|
|
||||
| `spring.grpc.client.channel.<name>.target` | **`target`, not `address`** |
|
||||
| `spring.grpc.client.channel.<name>.user-agent` | |
|
||||
| `spring.grpc.client.channel.<name>.bypass-certificate-validation` | test/dev only |
|
||||
| `spring.grpc.client.channel.<name>.default.deadline` | **the default deadline gRPC otherwise lacks** |
|
||||
| `spring.grpc.client.channel.<name>.default.load-balancing-policy` | e.g. `round_robin` (default is `pick_first`) |
|
||||
| `spring.grpc.client.channel.<name>.inbound.message.max-size` | 4 MB default, for incoming **responses** |
|
||||
| `spring.grpc.client.channel.<name>.inbound.metadata.max-size` | |
|
||||
| `spring.grpc.client.channel.<name>.keepalive.time` / `.timeout` / `.without-calls` | |
|
||||
| `spring.grpc.client.channel.<name>.idle.timeout` | |
|
||||
| `spring.grpc.client.channel.<name>.ssl.enabled` / `.bundle` | |
|
||||
| `spring.grpc.client.channel.<name>.health.enabled` / `.service-name` | client-side health checking |
|
||||
| `spring.grpc.client.channel.<name>.service-config.*` | retry, load balancing, throttling |
|
||||
| `spring.grpc.client.enabled` | |
|
||||
| `spring.grpc.client.inprocess.enabled` | register the in-process channel factory |
|
||||
| `spring.grpc.client.observation.enabled` | |
|
||||
| `spring.grpc.client.channelfactory.enabled` | |
|
||||
|
||||
`service-config` maps to the gRPC service config record with `loadbalancing`, `method`,
|
||||
`retrythrottling` and `healthcheck` sections.
|
||||
|
||||
## 4. The two mistakes, and their symptoms
|
||||
|
||||
| Wrong | Right | Symptom |
|
||||
|---|---|---|
|
||||
| `spring.grpc.server.in-process.name` | `spring.grpc.server.inprocess.name` | property silently ignored; server never binds in-process |
|
||||
| `spring.grpc.client.channels.x.address` | `spring.grpc.client.channel.x.target` | `UNAVAILABLE: Unable to resolve host x` / `UnknownHostException: x` |
|
||||
|
||||
The second is the expensive one. An unmatched channel name is handed to the DNS resolver **as a
|
||||
literal target**, so the error names your logical channel as if it were a hostname — and sends you
|
||||
to investigate DNS, service discovery and networking instead of a typo. Both cost real time while
|
||||
building this repository.
|
||||
|
||||
**Guard against it:** add `spring-boot-configuration-processor` and your IDE will flag unknown
|
||||
`spring.grpc.*` keys. It will not catch a wrong value inside the `channel.<name>` map (the keys
|
||||
there are arbitrary), but it catches everything else.
|
||||
108
pom.xml
Normal file
108
pom.xml
Normal file
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Companion code for "Spring gRPC with Spring Boot 4" (ankurm.com).
|
||||
|
||||
mvn test
|
||||
|
||||
Nothing to install: protoc and the gRPC codegen plugin are downloaded as Maven artifacts, and
|
||||
every test runs over gRPC's in-process transport, so no port is bound.
|
||||
-->
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.0</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm.grpc</groupId>
|
||||
<artifactId>spring-grpc-boot4</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>Spring gRPC with Spring Boot 4</name>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!--
|
||||
Boot 4 promotes gRPC to a first-class starter. On Boot 3 you reached for the community
|
||||
spring-grpc project (or grpc-spring-boot-starter); these are maintained by the Boot team
|
||||
and version-managed by the Boot BOM.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-grpc-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-grpc-client</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-grpc-server-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-grpc-client-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.awaitility</groupId>
|
||||
<artifactId>awaitility</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<!--
|
||||
Generates Java from src/main/proto. protoc itself and the gRPC codegen plugin are
|
||||
resolved as Maven artifacts for the current OS and architecture, so there is nothing
|
||||
to install and the build is reproducible on any machine.
|
||||
-->
|
||||
<plugin>
|
||||
<groupId>io.github.ascopes</groupId>
|
||||
<artifactId>protobuf-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<protocVersion>${protobuf-java.version}</protocVersion>
|
||||
<binaryMavenPlugins>
|
||||
<binaryMavenPlugin>
|
||||
<groupId>io.grpc</groupId>
|
||||
<artifactId>protoc-gen-grpc-java</artifactId>
|
||||
<version>${grpc-java.version}</version>
|
||||
</binaryMavenPlugin>
|
||||
</binaryMavenPlugins>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>generate</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<redirectTestOutputToFile>false</redirectTestOutputToFile>
|
||||
<trimStackTrace>false</trimStackTrace>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
BIN
results-full.txt
Normal file
BIN
results-full.txt
Normal file
Binary file not shown.
19
src/main/java/com/ankurm/grpc/Application.java
Normal file
19
src/main/java/com/ankurm/grpc/Application.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.ankurm.grpc;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Companion application for
|
||||
* <a href="https://ankurm.com/spring-grpc-spring-boot-4/">Spring gRPC with Spring Boot 4</a>.
|
||||
*
|
||||
* <p>The service lives in {@code orders/}; the interesting material is in {@code src/test}, where
|
||||
* each failure mode is reproduced and then fixed.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class Application {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Application.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.ankurm.grpc.orders;
|
||||
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusException;
|
||||
import org.springframework.grpc.server.advice.GrpcAdvice;
|
||||
import org.springframework.grpc.server.advice.GrpcExceptionHandler;
|
||||
|
||||
/**
|
||||
* Centralised exception mapping -- the gRPC equivalent of {@code @RestControllerAdvice}.
|
||||
*
|
||||
* <h2>Why this matters more in gRPC than in REST</h2>
|
||||
* An exception that escapes a gRPC handler becomes {@code UNKNOWN} with a <b>null description</b>.
|
||||
* Not a 500 with a body you can read: {@code UNKNOWN}, and nothing else. gRPC deliberately refuses
|
||||
* to serialise your exception message across a service boundary, because doing so is an
|
||||
* information-disclosure risk.
|
||||
*
|
||||
* <p>So without mapping, every domain failure looks identical to every bug, and callers cannot tell
|
||||
* "this order does not exist" (never retry) from "the database is down" (retry). Retry policies,
|
||||
* circuit breakers and dashboards all key off the status code, so an unmapped exception does not
|
||||
* merely lose information -- it makes every downstream behaviour wrong.
|
||||
*
|
||||
* <h2>The two mechanisms</h2>
|
||||
* Spring gRPC offers both, and they compose:
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>Annotation-based</b> (this class): a {@code @GrpcAdvice} bean with
|
||||
* {@code @GrpcExceptionHandler} methods, one per exception type. Familiar, readable, and the
|
||||
* right default.</li>
|
||||
* <li><b>Functional</b>: a bean implementing
|
||||
* {@code org.springframework.grpc.server.exception.GrpcExceptionHandler}, a single method
|
||||
* {@code StatusException handleException(Throwable)}. Better for a catch-all, or for mapping
|
||||
* driven by data rather than by type.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Note the two interfaces share the simple name {@code GrpcExceptionHandler} in different
|
||||
* packages ({@code ...server.advice} for the annotation, {@code ...server.exception} for the
|
||||
* functional interface). An IDE will happily import the wrong one and the failure is a confusing
|
||||
* "annotation type not applicable" or "is not a functional interface".
|
||||
*
|
||||
* <h2>Returning trailers</h2>
|
||||
* A handler returns {@link StatusException}, which can carry {@link Metadata}. Put machine-readable
|
||||
* context there rather than formatting it into the description string: descriptions are for humans
|
||||
* reading logs, trailers are for code making decisions.
|
||||
*/
|
||||
@GrpcAdvice
|
||||
public class OrderExceptionAdvice {
|
||||
|
||||
/** Domain "not found" becomes NOT_FOUND, with the offending id in a trailer. */
|
||||
@GrpcExceptionHandler(OrderNotFoundException.class)
|
||||
public StatusException handleNotFound(OrderNotFoundException ex) {
|
||||
Metadata trailers = new Metadata();
|
||||
trailers.put(OrderServiceImpl.REASON_KEY, "ORDER_NOT_FOUND");
|
||||
return Status.NOT_FOUND
|
||||
.withDescription(ex.getMessage())
|
||||
.asException(trailers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bad input becomes INVALID_ARGUMENT.
|
||||
*
|
||||
* <p>Both of these are in the "never retry" family, which is the useful half of the
|
||||
* distinction: a client that retries a NOT_FOUND is wasting capacity on an answer that will
|
||||
* not change.
|
||||
*/
|
||||
@GrpcExceptionHandler(IllegalArgumentException.class)
|
||||
public StatusException handleBadInput(IllegalArgumentException ex) {
|
||||
return Status.INVALID_ARGUMENT
|
||||
.withDescription(ex.getMessage())
|
||||
.asException();
|
||||
}
|
||||
|
||||
/**
|
||||
* A catch-all, so that an unexpected bug still produces a <em>deliberate</em> INTERNAL rather
|
||||
* than a bare UNKNOWN.
|
||||
*
|
||||
* <p>Deliberately does <b>not</b> include {@code ex.getMessage()} in the description: an
|
||||
* unexpected exception's message may contain SQL, file paths or user data, and this crosses a
|
||||
* service boundary. Log it server-side; send the caller a correlation id instead.
|
||||
*/
|
||||
@GrpcExceptionHandler(Exception.class)
|
||||
public StatusException handleEverythingElse(Exception ex) {
|
||||
return Status.INTERNAL
|
||||
.withDescription("internal error")
|
||||
.asException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.grpc.orders;
|
||||
|
||||
/**
|
||||
* An ordinary domain exception. It knows nothing about gRPC -- no {@code Status}, no
|
||||
* {@code StatusRuntimeException}, no transport concepts at all.
|
||||
*
|
||||
* <p>That is the point. Mapping it to a gRPC status is the job of
|
||||
* {@link OrderExceptionAdvice}, exactly as mapping a domain exception to an HTTP status is the
|
||||
* job of a {@code @RestControllerAdvice} rather than of the service that throws it.
|
||||
*/
|
||||
public class OrderNotFoundException extends RuntimeException {
|
||||
|
||||
private final String orderId;
|
||||
|
||||
public OrderNotFoundException(String orderId) {
|
||||
super("no order with id " + orderId);
|
||||
this.orderId = orderId;
|
||||
}
|
||||
|
||||
public String orderId() {
|
||||
return orderId;
|
||||
}
|
||||
}
|
||||
231
src/main/java/com/ankurm/grpc/orders/OrderServiceImpl.java
Normal file
231
src/main/java/com/ankurm/grpc/orders/OrderServiceImpl.java
Normal file
@@ -0,0 +1,231 @@
|
||||
package com.ankurm.grpc.orders;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.grpc.Context;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* The service implementation.
|
||||
*
|
||||
* <p>On Spring Boot 4 a {@code @Service} bean that extends the generated {@code ImplBase} is
|
||||
* discovered automatically: it is a {@link io.grpc.BindableService}, and
|
||||
* {@code GrpcServerServicesAutoConfiguration} registers every such bean with the server. There is
|
||||
* no {@code @GrpcService} annotation to apply and no registration code to write.
|
||||
*/
|
||||
@Service
|
||||
public class OrderServiceImpl extends OrderServiceGrpc.OrderServiceImplBase {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OrderServiceImpl.class);
|
||||
|
||||
/** Incremented whenever {@link #slowCall} is entered -- lets tests count server-side attempts. */
|
||||
private final AtomicInteger slowCallAttempts = new AtomicInteger();
|
||||
|
||||
/** Set when a server-streaming call notices that its client has gone away. */
|
||||
private final AtomicInteger cancellationsObserved = new AtomicInteger();
|
||||
|
||||
/** How far ListOrders got before being cancelled; proves the server stopped early. */
|
||||
private final AtomicInteger lastListProgress = new AtomicInteger();
|
||||
|
||||
/**
|
||||
* Note what this method does <b>not</b> do: it throws plain domain exceptions and never
|
||||
* mentions {@link Status}. {@link OrderExceptionAdvice} maps them, exactly as a
|
||||
* {@code @RestControllerAdvice} maps exceptions to HTTP statuses.
|
||||
*/
|
||||
@Override
|
||||
public void getOrder(GetOrderRequest request, StreamObserver<Order> responseObserver) {
|
||||
if (request.getId().isBlank()) {
|
||||
throw new IllegalArgumentException("order id must not be blank");
|
||||
}
|
||||
if (request.getId().startsWith("missing-")) {
|
||||
throw new OrderNotFoundException(request.getId());
|
||||
}
|
||||
if (request.getId().equals("boom")) {
|
||||
// An unexpected bug, to show what the catch-all handler does with it.
|
||||
throw new IllegalStateException("simulated internal failure with sensitive detail");
|
||||
}
|
||||
responseObserver.onNext(order(request.getId()));
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Server streaming, written the way it should be: <b>checking for cancellation between
|
||||
* messages</b>.
|
||||
*
|
||||
* <p>If the client goes away -- it cancelled, it timed out, its process died -- gRPC does not
|
||||
* interrupt your thread. It sets a flag on the {@link Context}. A server that never checks that
|
||||
* flag keeps computing, keeps querying the database, and keeps calling {@code onNext} into a
|
||||
* closed stream for the full duration of the work. On a long stream that is a real and
|
||||
* surprisingly common source of wasted capacity.
|
||||
*/
|
||||
@Override
|
||||
public void listOrders(ListOrdersRequest request, StreamObserver<Order> responseObserver) {
|
||||
int count = request.getCount();
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (Context.current().isCancelled()) {
|
||||
cancellationsObserved.incrementAndGet();
|
||||
lastListProgress.set(i);
|
||||
log.info("client cancelled after {} of {} messages -- stopping work", i, count);
|
||||
// Do NOT call onCompleted/onError here: the stream is already closed. Just return.
|
||||
return;
|
||||
}
|
||||
responseObserver.onNext(order("order-" + i));
|
||||
if (request.getDelayMillis() > 0) {
|
||||
sleep(request.getDelayMillis());
|
||||
}
|
||||
}
|
||||
lastListProgress.set(count);
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Client streaming. The returned observer receives the client's messages; the response is sent
|
||||
* exactly once, from {@code onCompleted}.
|
||||
*/
|
||||
@Override
|
||||
public StreamObserver<Order> submitOrders(StreamObserver<SubmitSummary> responseObserver) {
|
||||
AtomicInteger accepted = new AtomicInteger();
|
||||
AtomicLong total = new AtomicLong();
|
||||
|
||||
return new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(Order value) {
|
||||
accepted.incrementAndGet();
|
||||
total.addAndGet(value.getAmountCents());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
// The CLIENT failed or cancelled. The stream is already dead -- responding here
|
||||
// throws. Log and release resources, nothing else.
|
||||
log.info("client-streaming call failed: {}", t.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
responseObserver.onNext(SubmitSummary.newBuilder()
|
||||
.setAccepted(accepted.get())
|
||||
.setTotalCents(total.get())
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bidirectional streaming.
|
||||
*
|
||||
* <p><b>The thread-safety rule that is easy to miss:</b> a {@link StreamObserver} is not
|
||||
* thread-safe. In a bidi call it is legal to call {@code onNext} on the response observer from
|
||||
* a different thread than the one delivering requests -- but only if you serialise those calls
|
||||
* yourself. Here every response is emitted from inside {@code onNext}, on the delivering
|
||||
* thread, which is the simplest correct arrangement.
|
||||
*/
|
||||
@Override
|
||||
public StreamObserver<Order> sync(StreamObserver<Order> responseObserver) {
|
||||
return new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(Order value) {
|
||||
responseObserver.onNext(value.toBuilder().setStatus(Order.Status.PAID).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
log.info("bidi call failed: {}", t.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns a payload of exactly the requested size, for exercising message size limits. */
|
||||
@Override
|
||||
public void getLargePayload(SizeRequest request, StreamObserver<Payload> responseObserver) {
|
||||
byte[] data = new byte[Math.max(0, request.getSizeBytes())];
|
||||
responseObserver.onNext(Payload.newBuilder().setData(ByteString.copyFrom(data)).build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
|
||||
/** Sleeps, so deadlines and cancellation can be exercised deterministically. */
|
||||
@Override
|
||||
public void slowCall(SlowRequest request, StreamObserver<Order> responseObserver) {
|
||||
slowCallAttempts.incrementAndGet();
|
||||
sleep(request.getSleepMillis());
|
||||
if (Context.current().isCancelled()) {
|
||||
// The deadline already expired. The client has long since given up; sending now throws
|
||||
// and adds nothing but noise to the logs.
|
||||
log.info("slowCall finished but the context was already cancelled -- not responding");
|
||||
return;
|
||||
}
|
||||
responseObserver.onNext(order("slow"));
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Always fails, with a machine-readable reason attached as trailing metadata.
|
||||
*
|
||||
* <p>Returning a bare {@code Status.INTERNAL} tells a caller nothing they can act on. Attaching
|
||||
* trailers -- or, better, a {@code google.rpc.Status} with typed detail messages -- is how gRPC
|
||||
* carries structured errors. This uses plain metadata to keep the proto dependency-free.
|
||||
*/
|
||||
@Override
|
||||
public void alwaysFails(GetOrderRequest request, StreamObserver<Order> responseObserver) {
|
||||
Metadata trailers = new Metadata();
|
||||
trailers.put(REASON_KEY, "ORDER_LOCKED");
|
||||
trailers.put(RETRY_AFTER_KEY, "30");
|
||||
responseObserver.onError(new StatusRuntimeException(
|
||||
Status.FAILED_PRECONDITION.withDescription("order " + request.getId() + " is locked"),
|
||||
trailers));
|
||||
}
|
||||
|
||||
public static final Metadata.Key<String> REASON_KEY =
|
||||
Metadata.Key.of("x-failure-reason", Metadata.ASCII_STRING_MARSHALLER);
|
||||
public static final Metadata.Key<String> RETRY_AFTER_KEY =
|
||||
Metadata.Key.of("x-retry-after-seconds", Metadata.ASCII_STRING_MARSHALLER);
|
||||
|
||||
private static Order order(String id) {
|
||||
return Order.newBuilder()
|
||||
.setId(id)
|
||||
.setCustomer("ankur")
|
||||
.setAmountCents(1_999)
|
||||
.setStatus(Order.Status.PENDING)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static void sleep(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
public int slowCallAttempts() {
|
||||
return slowCallAttempts.get();
|
||||
}
|
||||
|
||||
public int cancellationsObserved() {
|
||||
return cancellationsObserved.get();
|
||||
}
|
||||
|
||||
public int lastListProgress() {
|
||||
return lastListProgress.get();
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
slowCallAttempts.set(0);
|
||||
cancellationsObserved.set(0);
|
||||
lastListProgress.set(0);
|
||||
}
|
||||
}
|
||||
80
src/main/proto/orders.proto
Normal file
80
src/main/proto/orders.proto
Normal file
@@ -0,0 +1,80 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package com.ankurm.grpc.orders;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "com.ankurm.grpc.orders";
|
||||
option java_outer_classname = "OrdersProto";
|
||||
|
||||
// A deliberately small service that still exercises all four gRPC call types, because the
|
||||
// interesting failure modes differ sharply between them.
|
||||
service OrderService {
|
||||
|
||||
// Unary: one request, one response. The 90% case, and the only one most tutorials cover.
|
||||
rpc GetOrder (GetOrderRequest) returns (Order);
|
||||
|
||||
// Server streaming: one request, many responses. Where deadlines and cancellation start to
|
||||
// matter, and where "the client went away" becomes something you have to handle.
|
||||
rpc ListOrders (ListOrdersRequest) returns (stream Order);
|
||||
|
||||
// Client streaming: many requests, one response. Where flow control and half-close appear.
|
||||
rpc SubmitOrders (stream Order) returns (SubmitSummary);
|
||||
|
||||
// Bidirectional streaming: the one that will teach you about StreamObserver thread safety.
|
||||
rpc Sync (stream Order) returns (stream Order);
|
||||
|
||||
// Used by the troubleshooting suite to return payloads of a requested size, so that message
|
||||
// size limits can be demonstrated rather than described.
|
||||
rpc GetLargePayload (SizeRequest) returns (Payload);
|
||||
|
||||
// Sleeps for a requested duration so that deadlines, cancellation and keepalive behaviour can
|
||||
// be exercised deterministically.
|
||||
rpc SlowCall (SlowRequest) returns (Order);
|
||||
|
||||
// Always fails, with rich error details attached.
|
||||
rpc AlwaysFails (GetOrderRequest) returns (Order);
|
||||
}
|
||||
|
||||
message GetOrderRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message ListOrdersRequest {
|
||||
int32 count = 1;
|
||||
// Milliseconds to wait between emitted messages; lets tests exercise slow producers.
|
||||
int32 delay_millis = 2;
|
||||
}
|
||||
|
||||
message Order {
|
||||
string id = 1;
|
||||
string customer = 2;
|
||||
int64 amount_cents = 3;
|
||||
Status status = 4;
|
||||
|
||||
enum Status {
|
||||
// proto3 requires the zero value to be the "unset" case. Naming it UNSPECIFIED rather than
|
||||
// reusing a real state is a convention worth keeping: it makes "field absent" distinguishable
|
||||
// from "field genuinely has the first value", which is otherwise impossible in proto3.
|
||||
STATUS_UNSPECIFIED = 0;
|
||||
PENDING = 1;
|
||||
PAID = 2;
|
||||
CANCELLED = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message SubmitSummary {
|
||||
int32 accepted = 1;
|
||||
int64 total_cents = 2;
|
||||
}
|
||||
|
||||
message SizeRequest {
|
||||
int32 size_bytes = 1;
|
||||
}
|
||||
|
||||
message Payload {
|
||||
bytes data = 1;
|
||||
}
|
||||
|
||||
message SlowRequest {
|
||||
int32 sleep_millis = 1;
|
||||
}
|
||||
147
src/test/java/com/ankurm/grpc/_01_basics/FourCallTypesTest.java
Normal file
147
src/test/java/com/ankurm/grpc/_01_basics/FourCallTypesTest.java
Normal file
@@ -0,0 +1,147 @@
|
||||
package com.ankurm.grpc._01_basics;
|
||||
|
||||
import com.ankurm.grpc.orders.GetOrderRequest;
|
||||
import com.ankurm.grpc.orders.ListOrdersRequest;
|
||||
import com.ankurm.grpc.orders.Order;
|
||||
import com.ankurm.grpc.orders.OrderServiceGrpc;
|
||||
import com.ankurm.grpc.orders.SubmitSummary;
|
||||
import com.ankurm.grpc.support.GrpcTestBase;
|
||||
import com.ankurm.grpc.support.Report;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* All four gRPC call types against a Spring Boot 4 server, with the differences that matter.
|
||||
*
|
||||
* <p>Most introductions show unary and stop. The three streaming forms are where the interesting
|
||||
* failure modes live, and they behave differently enough that experience with one does not transfer
|
||||
* to the others.
|
||||
*/
|
||||
class FourCallTypesTest extends GrpcTestBase {
|
||||
|
||||
@Test
|
||||
void unary() {
|
||||
Report.title("Unary: one request, one response");
|
||||
|
||||
Order order = blockingStub().getOrder(GetOrderRequest.newBuilder().setId("abc").build());
|
||||
|
||||
Report.bullet("id=%s customer=%s amountCents=%d status=%s",
|
||||
order.getId(), order.getCustomer(), order.getAmountCents(), order.getStatus());
|
||||
assertThat(order.getId()).isEqualTo("abc");
|
||||
|
||||
Report.takeaway("A @Service extending the generated ImplBase is registered automatically:");
|
||||
Report.takeaway("it is a BindableService, and Boot 4 wires every such bean into the server.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void serverStreaming() {
|
||||
Report.title("Server streaming: one request, many responses");
|
||||
|
||||
Iterator<Order> it = blockingStub()
|
||||
.listOrders(ListOrdersRequest.newBuilder().setCount(5).build());
|
||||
|
||||
List<String> ids = new ArrayList<>();
|
||||
it.forEachRemaining(o -> ids.add(o.getId()));
|
||||
|
||||
Report.bullet("received %d messages: %s", ids.size(), ids);
|
||||
assertThat(ids).hasSize(5);
|
||||
|
||||
Report.takeaway("The blocking stub returns an Iterator. Each next() may block, and an");
|
||||
Report.takeaway("exception surfaces mid-iteration -- so a try/catch around the loop body");
|
||||
Report.takeaway("is not the same as one around the call. Wrap the whole iteration.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientStreaming() throws Exception {
|
||||
Report.title("Client streaming: many requests, one response");
|
||||
|
||||
AtomicReference<SubmitSummary> summary = new AtomicReference<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
|
||||
StreamObserver<Order> requests = asyncStub().submitOrders(new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(SubmitSummary value) {
|
||||
summary.set(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
done.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
requests.onNext(Order.newBuilder().setId("o" + i).setAmountCents(1000).build());
|
||||
}
|
||||
// Half-close: "I have no more requests". Forgetting this is the single most common
|
||||
// client-streaming bug -- the server's onCompleted never fires and the call hangs until
|
||||
// the deadline, or forever if there is no deadline.
|
||||
requests.onCompleted();
|
||||
|
||||
assertThat(done.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
Report.bullet("accepted=%d totalCents=%d",
|
||||
summary.get().getAccepted(), summary.get().getTotalCents());
|
||||
assertThat(summary.get().getAccepted()).isEqualTo(3);
|
||||
|
||||
Report.takeaway("requests.onCompleted() is the half-close. Without it the call hangs until");
|
||||
Report.takeaway("the deadline -- and if you did not set a deadline, it hangs forever.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bidirectionalStreaming() throws Exception {
|
||||
Report.title("Bidirectional streaming: many requests, many responses");
|
||||
|
||||
List<Order> received = new ArrayList<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
|
||||
StreamObserver<Order> requests = asyncStub().sync(new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(Order value) {
|
||||
received.add(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
done.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
requests.onNext(Order.newBuilder().setId("b" + i).setStatus(Order.Status.PENDING).build());
|
||||
}
|
||||
requests.onCompleted();
|
||||
|
||||
assertThat(done.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
Report.bullet("sent 4, received %d, all status=%s",
|
||||
received.size(), received.isEmpty() ? "-" : received.getFirst().getStatus());
|
||||
assertThat(received).hasSize(4);
|
||||
assertThat(received).allMatch(o -> o.getStatus() == Order.Status.PAID);
|
||||
|
||||
Report.takeaway("Request and response streams are INDEPENDENT. The server may respond");
|
||||
Report.takeaway("before you finish sending, or not at all until you half-close. Do not");
|
||||
Report.takeaway("assume a request/response pairing -- that is your protocol's job, not gRPC's.");
|
||||
Report.takeaway("");
|
||||
Report.takeaway("StreamObserver is NOT thread-safe. Calling onNext from two threads without");
|
||||
Report.takeaway("synchronisation corrupts the stream, and the symptom is usually a");
|
||||
Report.takeaway("deserialization error on the far side rather than anything pointing here.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.ankurm.grpc._02_troubleshooting;
|
||||
|
||||
import com.ankurm.grpc.orders.GetOrderRequest;
|
||||
import com.ankurm.grpc.orders.ListOrdersRequest;
|
||||
import com.ankurm.grpc.orders.Order;
|
||||
import com.ankurm.grpc.orders.OrderServiceGrpc;
|
||||
import com.ankurm.grpc.orders.OrderServiceImpl;
|
||||
import com.ankurm.grpc.orders.Payload;
|
||||
import com.ankurm.grpc.orders.SizeRequest;
|
||||
import com.ankurm.grpc.orders.SlowRequest;
|
||||
import com.ankurm.grpc.support.GrpcTestBase;
|
||||
import com.ankurm.grpc.support.Report;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
/**
|
||||
* <h2>The failures that cost hours</h2>
|
||||
*
|
||||
* <p>Each test here <b>reproduces</b> a problem that is hard to diagnose from its symptom, then
|
||||
* shows the fix. They are grouped in one class because they share a server and the suite is faster
|
||||
* that way; each is independent.
|
||||
*
|
||||
* <p>Marked in the transcript as {@code [PROBLEM]} and {@code [FIX]}.
|
||||
*/
|
||||
class HardToDiagnoseTest extends GrpcTestBase {
|
||||
|
||||
@Autowired
|
||||
OrderServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void reset() {
|
||||
service.reset();
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
// 1. The 4 MB message limit
|
||||
// =======================================================================================
|
||||
|
||||
/**
|
||||
* <b>Symptom:</b> {@code RESOURCE_EXHAUSTED: gRPC message exceeds maximum size 4194304}.
|
||||
* Works in dev, fails in production, or works for 99% of requests and fails for the big ones.
|
||||
*
|
||||
* <p><b>Why it is confusing:</b> the limit is <em>per message</em>, it applies to the
|
||||
* <em>receiving</em> side, and client and server are configured <em>separately</em>. So raising
|
||||
* it on the server does nothing for a large <em>response</em>, which is the client's inbound
|
||||
* limit. Half the reports of "I raised the limit and it still fails" are this asymmetry.
|
||||
*/
|
||||
@Test
|
||||
void inProcessTransportDoesNotEnforceMessageSizeLimits() {
|
||||
Report.title("1. The 4 MB limit -- and why your integration tests will not catch it");
|
||||
|
||||
int fourMb = 4 * 1024 * 1024;
|
||||
|
||||
Report.section("Sending a 4 MB + 1 KB response over the IN-PROCESS transport");
|
||||
Payload big = blockingStub().getLargePayload(
|
||||
SizeRequest.newBuilder().setSizeBytes(fourMb + 1024).build());
|
||||
Report.bullet("received %,d bytes -- no error", big.getData().size());
|
||||
|
||||
assertThat(big.getData().size())
|
||||
.as("in-process transport happily delivers a message over the default limit")
|
||||
.isEqualTo(fourMb + 1024);
|
||||
|
||||
Report.problem("This message is OVER the 4 MB default limit and nothing complained.");
|
||||
Report.problem("The in-process transport passes message objects BY REFERENCE -- it never");
|
||||
Report.problem("serialises them -- so there are no bytes to measure and the limit cannot");
|
||||
Report.problem("be applied. Over a real Netty transport the same call fails with:");
|
||||
Report.problem(" RESOURCE_EXHAUSTED: gRPC message exceeds maximum size 4194304");
|
||||
|
||||
Report.takeaway("This is a genuinely nasty testing trap. In-process transport is otherwise");
|
||||
Report.takeaway("an excellent test transport -- interceptors, statuses, deadlines and");
|
||||
Report.takeaway("cancellation all behave correctly -- but message size limits, compression");
|
||||
Report.takeaway("and anything else that depends on the wire format do NOT apply.");
|
||||
Report.takeaway("A payload-size regression will pass every in-process test you have.");
|
||||
Report.takeaway("");
|
||||
Report.takeaway("Test size limits against a real port, or not at all -- but do not believe");
|
||||
Report.takeaway("a green in-process suite on this point.");
|
||||
|
||||
Report.section("The fix in production, for when you do hit it");
|
||||
Report.fix("The limit applies to the RECEIVER, and the two sides are configured separately:");
|
||||
Report.fix(" large RESPONSE -> client inbound limit");
|
||||
Report.fix(" spring.grpc.client.channel.<name>.inbound.message.max-size=16MB");
|
||||
Report.fix(" large REQUEST -> server inbound limit");
|
||||
Report.fix(" spring.grpc.server.inbound.message.max-size=16MB");
|
||||
Report.fix("Per call, without touching configuration:");
|
||||
Report.fix(" stub.withMaxInboundMessageSize(16 * 1024 * 1024)");
|
||||
Report.fix("");
|
||||
Report.fix("Half of all 'I raised the limit and it still fails' reports are this");
|
||||
Report.fix("asymmetry: raising it on the server does nothing for a large RESPONSE.");
|
||||
Report.fix("");
|
||||
Report.fix("Do not raise it globally. A large limit turns a malformed request into an OOM.");
|
||||
Report.fix("Prefer streaming: the limit is per MESSAGE, so 1000 small messages are fine.");
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
// 2. No deadline
|
||||
// =======================================================================================
|
||||
|
||||
/**
|
||||
* <b>Symptom:</b> a thread pool fills up and the service stops responding, with no errors in
|
||||
* the logs. Or: a retry storm during a downstream slowdown.
|
||||
*
|
||||
* <p><b>Why it is confusing:</b> gRPC calls have <b>no default deadline</b>. A call with no
|
||||
* deadline waits forever. Nothing warns you, and in testing the server always responds quickly,
|
||||
* so it never surfaces until the day something downstream is slow.
|
||||
*/
|
||||
@Test
|
||||
void callsHaveNoDefaultDeadline() {
|
||||
Report.title("2. gRPC has NO default deadline -- a hung server hangs you forever");
|
||||
|
||||
Report.section("A call with an explicit deadline shorter than the server takes");
|
||||
long start = System.nanoTime();
|
||||
assertThatThrownBy(() -> blockingStub()
|
||||
.withDeadlineAfter(300, TimeUnit.MILLISECONDS)
|
||||
.slowCall(SlowRequest.newBuilder().setSleepMillis(3_000).build()))
|
||||
.isInstanceOf(StatusRuntimeException.class)
|
||||
.satisfies(e -> {
|
||||
Status s = ((StatusRuntimeException) e).getStatus();
|
||||
Report.bullet("%s after %d ms", s.getCode(),
|
||||
(System.nanoTime() - start) / 1_000_000);
|
||||
assertThat(s.getCode()).isEqualTo(Status.Code.DEADLINE_EXCEEDED);
|
||||
});
|
||||
|
||||
Report.problem("Without .withDeadlineAfter(...) that call would have blocked for the full");
|
||||
Report.problem("3 seconds -- and if the server never responded, forever.");
|
||||
|
||||
Report.section("The fix");
|
||||
Report.fix("Per call: stub.withDeadlineAfter(2, TimeUnit.SECONDS)");
|
||||
Report.fix("Per channel: register a DefaultDeadlineSetupClientInterceptor, or set");
|
||||
Report.fix(" spring.grpc.client.channel.<name>.default.deadline=2s");
|
||||
Report.fix("Treat a missing deadline as a code-review failure, like a missing timeout");
|
||||
Report.fix("on an HTTP client.");
|
||||
|
||||
Report.takeaway("A deadline is ABSOLUTE and propagates: if service A calls B with 2s");
|
||||
Report.takeaway("remaining, B sees 2s, not a fresh 2s. That is the property that stops a");
|
||||
Report.takeaway("deep call chain from multiplying its timeouts -- and the reason you should");
|
||||
Report.takeaway("set the deadline at the EDGE, not re-set it at every hop.");
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
// 3. Cancellation the server never notices
|
||||
// =======================================================================================
|
||||
|
||||
/**
|
||||
* <b>Symptom:</b> the client timed out ten minutes ago, and the server is still burning CPU and
|
||||
* database connections on its request.
|
||||
*
|
||||
* <p><b>Why it is confusing:</b> gRPC does not interrupt your thread when a call is cancelled.
|
||||
* It flips a flag on the {@link io.grpc.Context}. A server that never checks it keeps working.
|
||||
*/
|
||||
@Test
|
||||
void serverMustCheckForCancellationItself() throws Exception {
|
||||
Report.title("3. Cancellation does not interrupt your thread -- you must check for it");
|
||||
|
||||
Report.section("Client starts a 200-message stream, then cancels after a few messages");
|
||||
CountDownLatch got3 = new CountDownLatch(3);
|
||||
AtomicReference<Throwable> clientError = new AtomicReference<>();
|
||||
|
||||
io.grpc.stub.ClientCallStreamObserver<ListOrdersRequest> requestStream =
|
||||
new io.grpc.stub.ClientCallStreamObserver<>() {
|
||||
@Override public boolean isReady() { return true; }
|
||||
@Override public void setOnReadyHandler(Runnable r) { }
|
||||
@Override public void disableAutoInboundFlowControl() { }
|
||||
@Override public void request(int count) { }
|
||||
@Override public void setMessageCompression(boolean enable) { }
|
||||
@Override public void cancel(String message, Throwable cause) { }
|
||||
@Override public void onNext(ListOrdersRequest value) { }
|
||||
@Override public void onError(Throwable t) { }
|
||||
@Override public void onCompleted() { }
|
||||
};
|
||||
|
||||
io.grpc.Context.CancellableContext ctx = io.grpc.Context.current().withCancellation();
|
||||
ctx.run(() -> asyncStub().listOrders(
|
||||
ListOrdersRequest.newBuilder().setCount(200).setDelayMillis(20).build(),
|
||||
new StreamObserver<>() {
|
||||
@Override public void onNext(Order value) { got3.countDown(); }
|
||||
@Override public void onError(Throwable t) { clientError.set(t); }
|
||||
@Override public void onCompleted() { }
|
||||
}));
|
||||
|
||||
assertThat(got3.await(10, TimeUnit.SECONDS)).as("stream started").isTrue();
|
||||
Report.bullet("received 3 messages, now cancelling");
|
||||
ctx.cancel(new RuntimeException("client gave up"));
|
||||
|
||||
await().atMost(15, TimeUnit.SECONDS)
|
||||
.until(() -> service.cancellationsObserved() > 0);
|
||||
|
||||
Report.bullet("server noticed cancellation after %d of 200 messages", service.lastListProgress());
|
||||
assertThat(service.lastListProgress()).isLessThan(200);
|
||||
|
||||
Report.fix("The server loop checks Context.current().isCancelled() between messages and");
|
||||
Report.fix("returns. Without that check it would have produced all 200 messages into a");
|
||||
Report.fix("dead stream, doing every database read and every serialization for nothing.");
|
||||
|
||||
Report.takeaway("Any server handler that loops, or that does work in stages, should check");
|
||||
Report.takeaway("Context.current().isCancelled(). For blocking work, propagate the Context");
|
||||
Report.takeaway("to worker threads with Context.current().wrap(runnable) -- otherwise the");
|
||||
Report.takeaway("cancellation flag is invisible to them.");
|
||||
Report.takeaway("");
|
||||
Report.takeaway("Also: after cancellation the stream is CLOSED. Calling onNext/onCompleted");
|
||||
Report.takeaway("on it throws IllegalStateException. Just return.");
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
// 4. Errors that tell the caller nothing
|
||||
// =======================================================================================
|
||||
|
||||
/**
|
||||
* <b>Symptom:</b> {@code UNKNOWN} statuses everywhere, and callers that cannot tell a retryable
|
||||
* failure from a permanent one.
|
||||
*
|
||||
* <p><b>Why it is confusing:</b> any exception that escapes a gRPC handler becomes
|
||||
* {@code UNKNOWN} with <em>no message</em>, because leaking exception text across a service
|
||||
* boundary would be an information disclosure risk. So the useful part of your error is
|
||||
* discarded by default.
|
||||
*/
|
||||
@Test
|
||||
void statusCodesAndTrailersCarryTheActualReason() {
|
||||
Report.title("4. Errors: UNKNOWN by default, useful only if you make them so");
|
||||
|
||||
Report.section("A handler that fails with a proper status and trailing metadata");
|
||||
assertThatThrownBy(() -> blockingStub()
|
||||
.alwaysFails(GetOrderRequest.newBuilder().setId("o-42").build()))
|
||||
.isInstanceOf(StatusRuntimeException.class)
|
||||
.satisfies(e -> {
|
||||
StatusRuntimeException sre = (StatusRuntimeException) e;
|
||||
Report.bullet("code : %s", sre.getStatus().getCode());
|
||||
Report.bullet("description : %s", sre.getStatus().getDescription());
|
||||
|
||||
Metadata trailers = sre.getTrailers();
|
||||
String reason = trailers == null ? null : trailers.get(OrderServiceImpl.REASON_KEY);
|
||||
String retryAfter = trailers == null ? null : trailers.get(OrderServiceImpl.RETRY_AFTER_KEY);
|
||||
Report.bullet("x-failure-reason : %s", reason);
|
||||
Report.bullet("x-retry-after-seconds : %s", retryAfter);
|
||||
|
||||
assertThat(sre.getStatus().getCode()).isEqualTo(Status.Code.FAILED_PRECONDITION);
|
||||
assertThat(reason).isEqualTo("ORDER_LOCKED");
|
||||
});
|
||||
|
||||
Report.problem("If the handler had simply thrown IllegalStateException, the caller would");
|
||||
Report.problem("have received UNKNOWN with a null description. Nothing actionable at all.");
|
||||
|
||||
Report.fix("Throw StatusRuntimeException with a code that means something:");
|
||||
Report.fix(" NOT_FOUND / INVALID_ARGUMENT / FAILED_PRECONDITION -- do NOT retry");
|
||||
Report.fix(" UNAVAILABLE / DEADLINE_EXCEEDED / RESOURCE_EXHAUSTED -- retry may help");
|
||||
Report.fix("Attach machine-readable context as trailers, not in the description string.");
|
||||
|
||||
Report.takeaway("The status code is the contract. Clients, retry policies, circuit breakers");
|
||||
Report.takeaway("and dashboards all key off it, so choosing it carelessly makes every");
|
||||
Report.takeaway("downstream behaviour wrong -- most damagingly, it makes non-retryable");
|
||||
Report.takeaway("failures look retryable and turns one bad request into a storm.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.ankurm.grpc._03_exceptions;
|
||||
|
||||
import com.ankurm.grpc.orders.GetOrderRequest;
|
||||
import com.ankurm.grpc.orders.OrderServiceImpl;
|
||||
import com.ankurm.grpc.support.GrpcTestBase;
|
||||
import com.ankurm.grpc.support.Report;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Spring-style exception mapping for gRPC: {@code @GrpcAdvice} plus {@code @GrpcExceptionHandler},
|
||||
* the direct analogue of {@code @RestControllerAdvice} plus {@code @ExceptionHandler}.
|
||||
*
|
||||
* <p>The service throws plain domain exceptions and never mentions {@code Status}. The advice
|
||||
* translates. These tests show the three cases that matter: a mapped domain exception, a mapped
|
||||
* validation failure, and an <em>un</em>anticipated bug caught by the catch-all.
|
||||
*/
|
||||
class ExceptionMappingTest extends GrpcTestBase {
|
||||
|
||||
@Test
|
||||
void domainExceptionBecomesNotFoundWithTrailers() {
|
||||
Report.title("Exception mapping: a domain exception becomes NOT_FOUND");
|
||||
|
||||
assertThatThrownBy(() -> blockingStub()
|
||||
.getOrder(GetOrderRequest.newBuilder().setId("missing-42").build()))
|
||||
.isInstanceOf(StatusRuntimeException.class)
|
||||
.satisfies(e -> {
|
||||
StatusRuntimeException sre = (StatusRuntimeException) e;
|
||||
Metadata trailers = sre.getTrailers();
|
||||
Report.bullet("code : %s", sre.getStatus().getCode());
|
||||
Report.bullet("description : %s", sre.getStatus().getDescription());
|
||||
Report.bullet("x-failure-reason : %s",
|
||||
trailers == null ? null : trailers.get(OrderServiceImpl.REASON_KEY));
|
||||
|
||||
assertThat(sre.getStatus().getCode()).isEqualTo(Status.Code.NOT_FOUND);
|
||||
assertThat(sre.getStatus().getDescription()).contains("missing-42");
|
||||
assertThat(trailers).isNotNull();
|
||||
assertThat(trailers.get(OrderServiceImpl.REASON_KEY)).isEqualTo("ORDER_NOT_FOUND");
|
||||
});
|
||||
|
||||
Report.fix("The service method just did: throw new OrderNotFoundException(id);");
|
||||
Report.fix("@GrpcAdvice + @GrpcExceptionHandler(OrderNotFoundException.class) did the rest.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validationFailureBecomesInvalidArgument() {
|
||||
Report.title("Exception mapping: IllegalArgumentException becomes INVALID_ARGUMENT");
|
||||
|
||||
assertThatThrownBy(() -> blockingStub()
|
||||
.getOrder(GetOrderRequest.newBuilder().setId("").build()))
|
||||
.isInstanceOf(StatusRuntimeException.class)
|
||||
.satisfies(e -> {
|
||||
Status s = ((StatusRuntimeException) e).getStatus();
|
||||
Report.bullet("code : %s", s.getCode());
|
||||
Report.bullet("description : %s", s.getDescription());
|
||||
assertThat(s.getCode()).isEqualTo(Status.Code.INVALID_ARGUMENT);
|
||||
});
|
||||
|
||||
Report.takeaway("NOT_FOUND and INVALID_ARGUMENT are both in the 'never retry' family.");
|
||||
Report.takeaway("A client that retries either is burning capacity on an answer that");
|
||||
Report.takeaway("will not change. That distinction is the whole reason to map.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unexpectedExceptionBecomesInternalWithoutLeakingDetail() {
|
||||
Report.title("Exception mapping: the catch-all, and what it deliberately hides");
|
||||
|
||||
assertThatThrownBy(() -> blockingStub()
|
||||
.getOrder(GetOrderRequest.newBuilder().setId("boom").build()))
|
||||
.isInstanceOf(StatusRuntimeException.class)
|
||||
.satisfies(e -> {
|
||||
Status s = ((StatusRuntimeException) e).getStatus();
|
||||
Report.bullet("code : %s", s.getCode());
|
||||
Report.bullet("description : %s", s.getDescription());
|
||||
|
||||
assertThat(s.getCode()).isEqualTo(Status.Code.INTERNAL);
|
||||
assertThat(s.getDescription()).isEqualTo("internal error");
|
||||
assertThat(s.getDescription())
|
||||
.as("the original message must not cross the service boundary")
|
||||
.doesNotContain("sensitive detail");
|
||||
});
|
||||
|
||||
Report.problem("Without the advice this would have been UNKNOWN with a NULL description --");
|
||||
Report.problem("no code to branch on and no message to read.");
|
||||
Report.fix("With it, callers get a deliberate INTERNAL, and the sensitive text stays");
|
||||
Report.fix("server-side where it belongs. Log it with a correlation id and return that.");
|
||||
|
||||
Report.takeaway("Order matters: @GrpcExceptionHandler(Exception.class) is a catch-all, so");
|
||||
Report.takeaway("more specific handlers must exist for the types you care about -- they are");
|
||||
Report.takeaway("matched most-specific-first, exactly like @ExceptionHandler.");
|
||||
}
|
||||
}
|
||||
95
src/test/java/com/ankurm/grpc/support/GrpcTestBase.java
Normal file
95
src/test/java/com/ankurm/grpc/support/GrpcTestBase.java
Normal file
@@ -0,0 +1,95 @@
|
||||
package com.ankurm.grpc.support;
|
||||
|
||||
import com.ankurm.grpc.Application;
|
||||
import com.ankurm.grpc.orders.OrderServiceGrpc;
|
||||
import io.grpc.ManagedChannel;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.grpc.client.GrpcChannelFactory;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Base class for every demonstration: boots the application with gRPC on the <b>in-process
|
||||
* transport</b> and hands out stubs built on channels the test controls.
|
||||
*
|
||||
* <h2>Why in-process rather than a real port</h2>
|
||||
* The in-process transport is a real gRPC stack -- interceptors, metadata, statuses, deadlines,
|
||||
* flow control and cancellation all behave normally -- but it skips sockets and TLS. That makes
|
||||
* tests fast and free of port conflicts, and it is the right default for demonstrating protocol
|
||||
* behaviour.
|
||||
*
|
||||
* <p><b>What it does NOT reproduce</b>, and therefore what these tests deliberately cannot show:
|
||||
* anything about the network. Keepalives, GOAWAY frames, idle-timeout disconnects, load-balancer
|
||||
* behaviour, TLS negotiation and DNS re-resolution all need a socket. Those are documented in
|
||||
* {@code docs/06-network-and-keepalive.md} rather than asserted here, because a test that claims to
|
||||
* prove them over in-process transport would be lying.
|
||||
*
|
||||
* <p>Note also that <b>message size limits are enforced by the in-process transport</b> even though
|
||||
* no bytes cross a socket, because the limit is applied at the message layer. So the size-limit
|
||||
* demonstrations are genuine.
|
||||
*/
|
||||
@SpringBootTest(classes = Application.class)
|
||||
@TestPropertySource(properties = {
|
||||
// Bind gRPC to the in-process transport only. Without this Boot starts a Netty server on
|
||||
// a real port, which works but makes the suite slower and flakier on CI.
|
||||
//
|
||||
// NOTE the exact property names -- they are easy to get wrong and the failure is opaque:
|
||||
// spring.grpc.server.inprocess.name (NOT "in-process")
|
||||
// spring.grpc.client.channel.<name>.target (SINGULAR "channel"; and "target", NOT "address")
|
||||
//
|
||||
// Getting the client one wrong produces no configuration error at all. An unmatched channel
|
||||
// name is simply passed to the default name resolver as a DNS target, so you get
|
||||
// UNAVAILABLE: Unable to resolve host orders
|
||||
// Caused by: java.net.UnknownHostException: orders
|
||||
// which sends you to look at networking, DNS and service discovery rather than at a typo in
|
||||
// a property name. Both mistakes in this file cost real time while writing it.
|
||||
"spring.grpc.server.inprocess.name=orders-test",
|
||||
"spring.grpc.server.port=-1",
|
||||
"spring.grpc.client.inprocess.enabled=true",
|
||||
"spring.grpc.client.channel.orders.target=in-process:orders-test"
|
||||
})
|
||||
public abstract class GrpcTestBase {
|
||||
|
||||
@Autowired
|
||||
protected GrpcChannelFactory channels;
|
||||
|
||||
private final List<ManagedChannel> opened = new ArrayList<>();
|
||||
|
||||
/** The in-process target this suite's server listens on. */
|
||||
protected static final String TARGET = "in-process:orders-test";
|
||||
|
||||
/**
|
||||
* A channel to the test server, tracked so it is shut down after the test.
|
||||
*
|
||||
* <p>Note that this passes the <em>full target</em> rather than the logical channel name.
|
||||
* {@code GrpcChannelFactory} is a composite: it asks each registered factory whether it
|
||||
* {@code supports(target)} before any named-channel indirection is applied, so passing a bare
|
||||
* name to {@code createChannel} in this configuration fails with
|
||||
* {@code IllegalStateException: No grpc channel factory found that supports target : orders}.
|
||||
* Named channels are still the right thing in application code, where the property-configured
|
||||
* target is resolved for you -- see {@code NamedChannelTest}.
|
||||
*/
|
||||
protected ManagedChannel channel() {
|
||||
ManagedChannel channel = channels.createChannel(TARGET);
|
||||
opened.add(channel);
|
||||
return channel;
|
||||
}
|
||||
|
||||
protected OrderServiceGrpc.OrderServiceBlockingStub blockingStub() {
|
||||
return OrderServiceGrpc.newBlockingStub(channel());
|
||||
}
|
||||
|
||||
protected OrderServiceGrpc.OrderServiceStub asyncStub() {
|
||||
return OrderServiceGrpc.newStub(channel());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void closeChannels() {
|
||||
opened.forEach(ManagedChannel::shutdownNow);
|
||||
opened.clear();
|
||||
}
|
||||
}
|
||||
38
src/test/java/com/ankurm/grpc/support/Report.java
Normal file
38
src/test/java/com/ankurm/grpc/support/Report.java
Normal file
@@ -0,0 +1,38 @@
|
||||
package com.ankurm.grpc.support;
|
||||
|
||||
/** Formatting helper so every demonstration prints a readable, diffable transcript. */
|
||||
public final class Report {
|
||||
|
||||
private Report() {
|
||||
}
|
||||
|
||||
public static void title(String text) {
|
||||
System.out.println();
|
||||
System.out.println("=".repeat(78));
|
||||
System.out.println(text);
|
||||
System.out.println("=".repeat(78));
|
||||
}
|
||||
|
||||
public static void section(String text) {
|
||||
System.out.println();
|
||||
System.out.println("-- " + text + " " + "-".repeat(Math.max(0, 74 - text.length())));
|
||||
}
|
||||
|
||||
public static void bullet(String format, Object... args) {
|
||||
System.out.printf(" " + format + "%n", args);
|
||||
}
|
||||
|
||||
/** A problem being reproduced. */
|
||||
public static void problem(String format, Object... args) {
|
||||
System.out.printf(" [PROBLEM] " + format + "%n", args);
|
||||
}
|
||||
|
||||
/** The fix for the problem just reproduced. */
|
||||
public static void fix(String format, Object... args) {
|
||||
System.out.printf(" [FIX] " + format + "%n", args);
|
||||
}
|
||||
|
||||
public static void takeaway(String format, Object... args) {
|
||||
System.out.printf(">> " + format + "%n", args);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user