A gRPC call ends with an HTTP/2 stream reset and no grpc-status — how does the client decide what to report?
answer
- no trailer section means no real status
- the error code becomes the status
- refused means never processed
- clean-looking end, still a failure
- one reset kills exactly one call
basics
~20 sThe client maps the reset's HTTP/2 error code onto a gRPC status. REFUSED_STREAM means the server never processed the call and becomes UNAVAILABLE; CANCEL becomes CANCELLED; most others become INTERNAL, since the call ended abnormally.
solid answer
~40 sA `RST_STREAM` ends one call abruptly, and because the trailer section never arrives there is no `grpc-status` to read. The client therefore synthesises one from the HTTP/2 error code the reset carries. The mapping worth knowing is `REFUSED_STREAM` → `UNAVAILABLE`, which additionally asserts the server did **not** process the call, `CANCEL` → `CANCELLED`, `ENHANCE_YOUR_CALM` → `RESOURCE_EXHAUSTED`, `INADEQUATE_SECURITY` → `PERMISSION_DENIED`, and the remaining codes, including `NO_ERROR` on a call that never completed, → `INTERNAL`. The scope matters as much as the mapping: one call occupies exactly one stream, so a reset terminates that call and nothing else on the same connection. A `GOAWAY`, by contrast, names the highest stream identifier the server processed, which tells the client that calls above that identifier were definitively not processed.
go deeper
Know that a reset call never gets a real status, and that the client makes one up from the transport's error code. The number you see in the application came from the wire, not from the server's handler.
Explain the mapping and, in particular, why a clean-looking NO_ERROR on an incomplete call is still reported as a failure: the trailer section, not the stream closing, is the completion signal.
Use the distinction operationally. A refusal asserts the server did no work, which is a far stronger claim than a generic failure, and logging the transport cause turns one vague failure bucket into three separate investigations.
The design point is that abrupt ends carry a certainty gradient — definitely unprocessed, possibly processed, unknown. Deciding how much of your platform's safety rests on that gradient is a standard you set once, not per service.
## Why a synthesised status is needed at all Every normal gRPC call ends with a trailer section carrying `grpc-status`. A reset call ends *instead of* that — the stream is torn down and no trailing header block is ever sent. The caller still has to be told something, and "no status" is not a value the application can act on. So the client library turns the transport's own error code into a gRPC status, and the quality of that translation is what makes an abrupt failure readable. ## The mapping | HTTP/2 error code on the reset | reported status | what it asserts | |---|---|---| | `REFUSED_STREAM` | `UNAVAILABLE` | the server did not process this call at all | | `CANCEL` | `CANCELLED` | the call was cancelled rather than failed | | `ENHANCE_YOUR_CALM` | `RESOURCE_EXHAUSTED` | the peer is shedding load from this connection | | `INADEQUATE_SECURITY` | `PERMISSION_DENIED` | the connection's protection was judged insufficient | | `NO_ERROR` on an incomplete call | `INTERNAL` | a clean-looking end with no status is a protocol violation | | `PROTOCOL_ERROR`, `INTERNAL_ERROR`, others | `INTERNAL` | the call ended abnormally with no usable diagnosis | The `NO_ERROR` row is the interesting one. An error code that literally means "nothing went wrong" arriving on a call that never produced a status is not a success — it is a peer ending a stream it should have completed, and treating it as anything but a failure would deliver a call with no result as though it had one. ## The row that carries operational weight `REFUSED_STREAM` says more than "this failed". It is the transport asserting that the server **never began** the call — the stream was turned away before any handler ran. That is a much stronger claim than a generic failure, because it means: - no side effect was produced at the server, - nothing partial was written, - and a caller reasoning about whether to attempt the call again has a definite answer rather than a guess. Every other abnormal end leaves the question open: the server may have processed the declaration and failed while answering. Only a refusal, and the streams above the identifier a `GOAWAY` names, are known-unprocessed. What a caller then *does* with that certainty — retry policy, idempotency, deduplication — belongs to the RPC paradigm rather than to this wire, but the wire is where the certainty comes from. ## One call, one stream, one blast radius Because a call maps to exactly one HTTP/2 stream: 1. A reset kills **one call**. Other calls in flight on the same connection are untouched, which is precisely why per-call failure does not require tearing down a connection. 2. A stream identifier **identifies a call**, and only within its connection. It is not a request id, it is not stable across reconnects, and two connections will happily reuse the same number for unrelated calls — so it is useless as a correlation key across hops. 3. A connection-level event is a different blast radius entirely: it concerns which calls may still start, not how one call ended. That third point is the practical separation to hold. A reset answers "how did this call end"; a connection-closing frame answers "which calls were accepted before shutdown began", and the client turns the second into per-call outcomes for the calls above the named identifier. ## Reading it in production For the filing gateway, the useful habit is to log the transport-level cause alongside the reported status when a call ends abnormally. A run of `UNAVAILABLE` synthesised from refusals reads very differently from a run of `INTERNAL` synthesised from protocol errors: - **Refusals in a burst** point at a server or an in-path hop turning work away deliberately — shedding load, draining, or capping concurrent streams. - **Protocol errors** point at something malformed, often a hop rewriting the call. - **Resource-exhaustion resets** point at a peer objecting to this connection's behaviour specifically, rather than at the call's content. Without the transport cause, all three arrive at the application as a vague failure and look like the same incident. With it, they are three different investigations.
- What does a GOAWAY naming a last stream identifier tell a gRPC client?That calls on streams above that identifier were definitively not processed, so the client can report them the way it reports a refusal rather than as an ambiguous failure. Calls at or below it may have been processed and have to be treated as unknown.
- Why does NO_ERROR on an incomplete call become a failure rather than a success?Because the completion signal for a gRPC call is the trailer section, not the stream closing. A stream that ends cleanly without one has violated the protocol, so the only honest report is an internal failure — reporting success would fabricate a result the server never sent.
- Is the HTTP/2 stream identifier usable as a request id in logs?No. It identifies a call only within one connection, is reused freely across connections, and changes on reconnect. Correlation across hops needs an application key carried in metadata instead.
saying these in an interview costs you the question
- Reports a reset call as successful because NO_ERROR was sent
- Thinks one stream reset ends every call on the connection
- Treats a stream identifier as a stable cross-connection request id
- Assumes any abnormal end means the server did no work
- Expects a grpc-status to arrive after a reset