skip to content

Why does a non-OK RPC from k6's client.invoke() not fail the iteration, and how do you catch it?

level: seniorimportance: should knowfreq 51%

answer

  1. a status is data, not an exception
  2. the iteration never notices
  3. compare against a Status constant
  4. message is null when status is not OK
  5. no grpc_req_failed exists

basics

~20 s

In k6, client.invoke() returns a Response for any gRPC status, so a non-OK code is data rather than an exception. Compare response.status against grpc.StatusOK in a check; nothing in k6 classifies a gRPC status as a failure for you.

solid answer

~40 s

`client.invoke()` from `k6/net/grpc` returns a `Response` whether the RPC succeeded or not — only script-level problems such as an unloaded method or a missing connection throw. On a non-OK call, `response.status` holds the numeric gRPC code, `response.message` is `null`, and `response.error` holds the error, so the iteration simply carries on. You catch it by asserting the code yourself: `check(res, { 'status is OK': (r) => r && r.status === grpc.StatusOK })`. k6 still records `grpc_req_duration` for the call, tagged with the numeric gRPC code in the `status` tag, so a run of failing RPCs can show a perfectly healthy latency trend. There is no `grpc_req_failed` metric to lean on.

code

javascript · 21 lines
javascript
import grpc from 'k6/net/grpc';
import { check } from 'k6';

const client = new grpc.Client();
client.load([], 'echo.proto');

export default function () {
  client.connect('127.0.0.1:9000', { plaintext: true });

  // res is returned even when the RPC failed
  const res = client.invoke('echo.Echo/Say', { text: 'ping' });
  check(res, {
    'status is OK': (r) => r && r.status === grpc.StatusOK,
    'echo came back': (r) => r && r.message && r.message.text === 'ping',
  });
  if (res.status !== grpc.StatusOK) {
    console.error(`code ${res.status}: ${JSON.stringify(res.error)}`);
  }

  client.close();
}

go deeper

for a junior

Always pair a client.invoke() with a check on response.status === grpc.StatusOK. Without it, the script treats every server error as a success and you will not see it in the output.

for a middle

Describe the Response shape from memory — status, message, error, headers, trailers — and note that message is null whenever the status is not OK, which is why unguarded payload assertions throw.

for a senior

Be able to diagnose the quiet version of this: a green run whose grpc_req_duration looks great because every call short-circuited. Check the status tag distribution before trusting a gRPC latency figure.

for a principal

Decide what a gRPC failure means for your suite, since k6 supplies no built-in pass/fail classification for a status code the way it does for HTTP responses, and make that convention consistent across scripts.

## `invoke()` returns a `Response`, whatever the server said In `k6/net/grpc`, `client.invoke(method, request [, params])` hands back a `Response` object for both successful and unsuccessful RPCs. A non-OK status is *data*, not an exception. Only the surrounding script problems throw: an unloaded method, a client that never connected, a bad parameter key, a transport failure. The `Response` carries five fields: | field | on success | on a non-OK status | |---|---|---| | `status` | `grpc.StatusOK` | the numeric gRPC code | | `message` | the reply, converted to a plain object | `null` | | `error` | `null` | the error message, converted to a plain object | | `headers` | metadata headers from the server | metadata headers from the server | | `trailers` | metadata trailers from the server | metadata trailers from the server | Because nothing throws, the iteration keeps going, later calls run, and the run finishes looking clean. That is the whole failure mode: a suite that never inspects `status` reports success for a service that answered `NOT_FOUND` to every request. ## What does throw, so you can tell the two apart It helps to hold the short list of things that genuinely raise a script error, because everything not on it comes back as a `Response`: - Invoking a method the client never loaded — `method "…" not found in file descriptors`. - Invoking on a client that never connected, or one whose `close()` already ran — `no gRPC connection, you must call connect first`. - Passing an unrecognised key in the per-call parameters — `unknown param: "…"`. - Passing an empty method name, or a request object that cannot be serialised. - Calling `connect()` or `invoke()` from the init context at all. Every one of those is a mistake in the script. Nothing the *server* answers belongs on that list. ## Comparing the status k6 exports the codes as module constants so you compare a symbol, not a magic number: `grpc.StatusOK`, `grpc.StatusCanceled`, `grpc.StatusInvalidArgument`, `grpc.StatusDeadlineExceeded`, `grpc.StatusNotFound`, `grpc.StatusPermissionDenied`, `grpc.StatusUnavailable`, `grpc.StatusUnauthenticated`, and the rest of the set. The canonical assertion is: ```javascript check(res, { 'status is OK': (r) => r && r.status === grpc.StatusOK }); ``` The `r &&` guard matters, because `invoke()` can return `null` if the call itself errored out. Note that `res.message` is `null` whenever the status is not OK, so a check that reaches into `res.message.something` on a failed call throws a `TypeError` and ends the iteration — a different, and noisier, failure than the silent one. ## The metric keeps flowing either way k6 records **`grpc_req_duration`** — a trend, in milliseconds — for every RPC that reaches the server, regardless of the code that comes back. The sample is tagged with `status` set to the **numeric gRPC code**, plus `service`, `method`, `url` and `name` derived from the fully-qualified method. Two consequences follow: - A wall of `NOT_FOUND` responses still produces a healthy-looking `grpc_req_duration` — usually a *better*-looking one, because error paths are fast. - The `status` tag on a gRPC sample is not an HTTP status. `status: 5` is `NOT_FOUND`, not a redirect. Reading it as HTTP is how people end up puzzled by a `status` of `0` meaning success. There is also no `grpc_req_failed` counterpart to HTTP's `http_req_failed`; nothing in k6 classifies a gRPC status as pass or fail on your behalf. ## What to actually do about it 1. Assert the status on every call, with a `check()` comparing against `grpc.StatusOK` — or against the code you expect, if the scenario deliberately exercises an error path. 2. Log the failing code and `res.error` when the check fails, so the output names the code rather than just recording a false check. 3. Tag calls you expect to fail differently from the ones you do not, using the `tags` key in the per-call parameters, so their samples are separable afterwards. ## The nearby edge: `discardResponseMessage` Passing `{ discardResponseMessage: true }` in the per-call parameters tells k6 not to decode the reply body. Status checking still works — `status`, `headers`, `trailers` and `error` are all populated — but `message` is empty, so any assertion on the payload silently stops testing what it used to. It is a memory optimisation with a correctness footgun attached, and worth grepping for when someone reports that a content check "stopped catching anything". ## `asyncInvoke` behaves identically `client.asyncInvoke()` returns a promise that **resolves** with the same `Response` for a non-OK status; it rejects only for the same script-level errors that make `invoke()` throw. Wrapping calls in `.catch()` therefore catches none of the cases discussed here.

  • What is in the status tag on a grpc_req_duration sample?
    The numeric gRPC code for the call, not an HTTP status. A successful RPC is tagged `status: 0` because `OK` is code zero, and `status: 5` is `NOT_FOUND`. The sample also carries `service`, `method`, `url` and `name` derived from the fully-qualified method that was invoked.
  • Does client.asyncInvoke() reject on a non-OK status?
    No. Its promise resolves with the same `Response` object, carrying the non-OK `status`. It rejects only for the script-level errors that would also make `invoke()` throw, such as an unloaded method or a client that never connected, so a `.catch()` handler sees none of the failed-RPC cases.
  • Why can a payload assertion stop catching regressions?
    If someone passes `{ discardResponseMessage: true }` in the per-call parameters, k6 stops decoding the reply and `response.message` is empty. Status, headers, trailers and error still populate, so the status check keeps passing while any assertion on the body quietly tests nothing.

saying these in an interview costs you the question

  • Assumes a failed RPC throws and can be caught with try/catch
  • Expects a grpc_req_failed metric analogous to http_req_failed
  • Reads the status tag on grpc_req_duration as an HTTP status code
  • Compares response.status to the string 'OK' instead of grpc.StatusOK
  • Reaches into response.message without checking the status first
  • Thinks asyncInvoke rejects its promise when the server returns an error