1
0

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:
2026-07-31 22:53:13 +05:30
commit 8547981634
17 changed files with 1654 additions and 0 deletions

11
.gitignore vendored Normal file
View 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
View 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
View 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
View 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.

View 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
View 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

Binary file not shown.

View 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);
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View 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);
}
}

View 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;
}

View 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.");
}
}

View File

@@ -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.");
}
}

View File

@@ -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.");
}
}

View 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();
}
}

View 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);
}
}