Skip to content

gRPC calls

The grpc_egress effect calls a gRPC method. The current message becomes the request, and the response replaces it. Unary and server-streaming methods are supported.

Before you can call anything, the provider’s contract must be deployed — see gRPC and protobuf descriptors.

## Step: fetch-order
effects: [grpc_egress]
endpoint: https://orders.example.com:443
proto: orders
service: acme.orders.v1.OrderService
method: GetOrder

Step keys

Required

Key Meaning
endpoint Host and port. https:// selects TLS, http:// is cleartext. Accepts several, comma-separated — see Endpoint cascade
proto The descriptor name, as given to nexus proto deploy --name
service Fully-qualified service name, e.g. acme.orders.v1.OrderService
method Method name. Note this is not the HTTP method: key

Optional

Key Default Meaning
proto_version latest Pin to a contract version instead of following latest
deadline_ms 30000 Total budget for the call, from connect to last byte
connect_timeout_ms 10000 Budget for the TCP, TLS and HTTP/2 handshake
max_message_bytes the flow’s ceiling The ceiling for this step alone, overriding the flow’s max_message_bytes:
max_recv_bytes the step’s ceiling, so 1 MiB unless something raises it Largest single response message accepted
max_send_bytes the step’s ceiling, so 1 MiB unless something raises it Largest single request message sent
max_stream_messages 10000 Most messages accepted from a stream
max_stream_bytes the larger of max_recv_bytes and the step’s ceiling Most bytes accepted from a stream in total
tls_ca_cert — Path to a PEM CA certificate, for endpoints signed by a private CA
tls_client_cert — Path to a PEM client certificate, for mutual TLS
tls_client_key — Path to its PEM private key. Required with tls_client_cert
breaker_failure_threshold 3 Failed handshakes before an endpoint is skipped — see Circuit breaker
breaker_window_ms 60000 How long a failed handshake stays relevant
breaker_cooldown_ms 30000 How long a skipped endpoint rests before a probe. Must be at least connect_timeout_ms
breaker_cooldown_factor 2.0 Multiplier on the cooldown after a failed probe
breaker_cooldown_max_ms 300000 Ceiling for the escalating cooldown

Run nexus proto show to get service and method exactly right, and to see which methods are streaming.

The three size keys are one ceiling with two overrides

max_send_bytes and max_recv_bytes both start from the same number: the message ceiling in force at this step, resolved the way every other ceiling on the platform is — the platform default of 1 MiB, then the flow’s max_message_bytes:, then this step’s own. A flow that raises its ceiling once raises what it can send and receive over gRPC, and does not have to repeat the number. Set max_send_bytes or max_recv_bytes when one direction needs a different number from the other.

They used to default to a fixed 4 MiB instead, which no setting could reach: a flow declaring a 100 MiB ceiling still could not send more than 4 MiB, and lowering the ceiling did not lower what went on the wire. If you were relying on that 4 MiB, you now get 1 MiB unless the flow or the step says otherwise.

A request message over max_send_bytes never leaves the process. The encoded length is measured before the endpoint cascade runs, so nothing is dialled, no circuit breaker moves, and no server sees a partial message:

{"error":"grpc_egress: request message of 2097216 bytes exceeds max_send_bytes (1048576) — not sent; the cap is max_message_bytes unless the step sets max_send_bytes","step":"call"}

Unary calls

The response becomes the message:

---
flowmarkdown_version: "0.1"
flow: order-lookup
tenant: acme
effects: [grpc_egress]
---
## Step: check
```validate
$.orderId != null | "orderId is required" | syntactic
```
## Step: build-request
```ntd
{ "orderId": "{{ $.orderId }}" }
```
## Step: fetch
effects: [grpc_egress]
endpoint: https://orders.example.com:443
proto: orders
service: acme.orders.v1.OrderService
method: GetOrder
deadline_ms: 5000
Terminal window
$ curl -X POST http://localhost:9090/flows/acme/order-lookup/run \
-H "Authorization: Bearer $NEXUS_KEY" \
-H 'Content-Type: application/json' \
-d '{"orderId":"9007199254740993"}'
{"orderId":"9007199254740993","status":"SHIPPED","total":149.5}

Server-streaming calls

A server-streaming method returns many messages. They are collected in full and handed to the flow as an array:

## Step: search
effects: [grpc_egress]
endpoint: https://orders.example.com:443
proto: orders
service: acme.orders.v1.OrderService
method: SearchOrders
deadline_ms: 60000
max_stream_messages: 5000
max_stream_bytes: 16777216
[
{"orderId":"1001","status":"SHIPPED"},
{"orderId":"1002","status":"PENDING"}
]

A streaming method always produces an array, even for a single result. A unary method never produces one. The shape follows the contract, not the number of results — otherwise a search matching once would return a different shape than the same search matching twice, and every flow downstream would have to handle both.

An empty stream produces [], not null.

Iterate with a foreach fence, or reshape with a template:

## Step: summarise
```ntd
{
"count": {{ length($) }},
"ids": [{{ for o in $ }}"{{ o.orderId }}",{{ end }}]
}
```

Stream limits are errors, not truncation

max_recv_bytes bounds one message. It says nothing about a stream of ten million small ones, which is why the count and the total have their own bounds.

Exceeding either fails the step. It does not hand over what was collected so far. A truncated array presented as complete is a wrong answer the flow cannot detect, so the error is the safer outcome — and the message names which limit was hit:

{"error":"grpc_egress: max_stream_messages (5000) exceeded — the stream is refused rather than truncated","step":"search"}

The same applies to deadline_ms expiring mid-stream: collected messages are discarded and the error says how many were lost. Size the deadline for the whole stream, not for one message.

Note that the byte ceiling usually bites first. With the defaults, 1 MiB across 10 000 messages allows about 104 bytes each — realistic responses exceed that, so raise max_stream_bytes when you raise max_stream_messages. max_stream_bytes follows max_recv_bytes but never drops below the step’s ceiling, so lowering max_recv_bytes for one message does not quietly shrink what a whole stream may carry.

Trailing metadata

gRPC lets a server send metadata after the response body. Providers use it for continuation tokens and attachment references, and it is easy to miss because it is not in the body.

It is available as ctx.grpc_trailers, an object with lowercase keys:

## Step: fetch-page
effects: [grpc_egress]
endpoint: https://orders.example.com:443
proto: orders
service: acme.orders.v1.OrderService
method: SearchOrders
## Step: next-page-marker
```ntd
{
"orders": {{ $ }},
"resumeToken": "{{ ctx.grpc_trailers["x-resume-token"] }}"
}
```

Use bracket access: a key with a hyphen is not reachable with dotted notation.

Two things to know. Transport control and credential keys — grpc-status, grpc-message, set-cookie, authorization and similar — are filtered out and never reach the flow. And the variable is written on every path, including failure, so a failed call leaves an empty object rather than the previous call’s trailers.

TLS

https:// in endpoint turns on TLS. http:// is cleartext, with no negotiation.

By default the system trust store is used. Endpoints signed by a private or internal CA — the norm for organisation-to-organisation integration — will fail validation, so point at the CA certificate:

## Step: fetch
effects: [grpc_egress]
endpoint: https://orders.internal.example.com:443
tls_ca_cert: /etc/nexus/pki/internal-ca.pem
proto: orders
service: acme.orders.v1.OrderService
method: GetOrder

The file is read at call time, not at deploy time, so rotating a certificate does not require republishing flows. A CA certificate is public material; it does not belong in a secret.

Mutual TLS

When the endpoint authenticates both ends, present a client certificate and its key:

## Step: query
effects: [grpc_egress]
endpoint: https://partner.example.com:443
tls_ca_cert: /etc/nexus/pki/partner-ca.pem
tls_client_cert: /etc/nexus/pki/our-client.crt
tls_client_key: /etc/nexus/pki/our-client.key
proto: orders
service: acme.orders.v1.OrderService
method: GetOrder

Both keys or neither. One without the other is an error rather than a fall back to an anonymous connection — a certificate cannot be presented without its key, and connecting anyway would send the call unauthenticated to an endpoint configured for mutual authentication, where the failure surfaces at the far end as a transport error naming no cause.

Like the CA certificate, both are read at call time. That matters more here: issuing authorities commonly cap client certificates at two years, so rotation is a scheduled operation, and replacing the files is the whole procedure — no redeploy, no restart.

Keep the key readable only by the account running the platform. It is never copied into the platform’s own storage.

Endpoint cascade

Some providers publish several access points for the same operation — a primary node, a standby, sometimes further fallbacks on a different port. List them in order, separated by commas:

## Step: query
effects: [grpc_egress]
endpoint: https://primary.example.com:443, https://standby.example.com:443
proto: orders
service: acme.orders.v1.OrderService
method: Get

They are tried in the order you wrote them. Three variables tell your flow what happened:

Variable Meaning
ctx.grpc_endpoint_index Position in your list, 0 for the first. -1 if no endpoint was tried at all — see Circuit breaker
ctx.grpc_endpoint_used Scheme, host and port of the endpoint that answered; the empty string if none was tried
ctx.grpc_circuit_open true when every endpoint was skipped because its circuit was open, false otherwise

All three are set whether the call succeeded or failed, so a ## Fault: handler can read them too. This matters because a standby is often degraded — fewer fields populated, some operations unavailable — and your flow may need to behave differently:

## Step: proceed-only-on-the-primary
```condition
ctx.grpc_endpoint_index == 0
```

ctx.grpc_endpoint_used never contains a username or password, even if your endpoint does. The port is kept, because at some providers the port is what selects the environment.

What does and does not move to the next endpoint

Only a failure that transmitted nothing. A refused connection, a TLS handshake that never completed, a host that does not resolve — in those cases the far end never saw your request, so sending it elsewhere cannot perform the operation twice.

A server that answered is final. If the far end returns a status — including UNAVAILABLE while it drains or sheds load — your request was delivered. It may already have been acted on. Retrying it against another endpoint would risk a second submission, so it does not happen; the error reaches your flow.

A configuration error is final too. A missing certificate file, or TLS material on an http:// entry, stops the cascade rather than being skipped. Otherwise a misconfigured entry would be quietly masked by whichever endpoint happens to work.

One consequence worth knowing: because only the pre-request phase is retried, the extra time a cascade can cost is bounded by connect_timeout_ms per entry, not by deadline_ms. A long deadline does not multiply.

Circuit breaker

An endpoint that refuses to be contacted is not retried on every call. After breaker_failure_threshold failed handshakes in a row, that endpoint is skipped for breaker_cooldown_ms — the cascade moves past it exactly as it would after a failed connection, but in no time at all. When the cooldown runs out, the next call that would have used it makes a probe: an ordinary call, not a ping, so if the endpoint is back your flow gets its answer from the primary. If the probe fails too, the cooldown is multiplied by breaker_cooldown_factor, up to breaker_cooldown_max_ms.

The order you wrote still matters, and still decides everything while circuits are closed. Putting the endpoint most likely to answer first remains good practice — it is no longer compensation for a missing mechanism.

Only a failed handshake counts. This is a reachability breaker, and the whole of it is in one sentence: all it counts is a failed handshake, and all it saves is a handshake.

What happened Effect on the circuit
The call succeeded Counter back to zero
The server answered with a status — any status, including PERMISSION_DENIED or UNAVAILABLE Counter back to zero. The server answered, so the endpoint is demonstrably reachable
The connection or the handshake failed, and nothing was sent Counter +1
A configuration error — unreadable TLS material, TLS on an http:// entry Nothing. It says nothing either way about the endpoint

So a healthy endpoint that legitimately refuses your requests never leaves the rotation, and a mistake in your own step configuration is never reported to you as “circuit open”.

Exactly one probe is in flight at a time. Other calls arriving during a probe see the circuit as open and skip the endpoint rather than waiting — waiting would put back the cost the breaker exists to remove.

When every endpoint has an open circuit

The step fails at once, without touching the network, and says so distinctly:

{"error":"grpc_egress: all 4 endpoints have an open circuit; the earliest probe is in 12400ms","step":"query"}

ctx.grpc_circuit_open is true, ctx.grpc_endpoint_index is -1 and ctx.grpc_endpoint_used is empty. That is deliberately different from “everything is unreachable”, which is an outage your call just paid a handshake per entry to discover. This one is an outage already known, discovered for free — worth telling apart if your ## Fault: queues the message rather than rejecting it.

The five keys

Key Default Meaning
breaker_failure_threshold 3 Consecutive failed handshakes before the endpoint is skipped. Must be at least 1
breaker_window_ms 60000 How long a failed handshake stays relevant. Two at 09:00 and one at 17:00 are not the same fault. Must be at least 1
breaker_cooldown_ms 30000 How long an endpoint is skipped before a probe is due. Must be at least connect_timeout_ms, or a probe would still be in flight when the next became due
breaker_cooldown_factor 2.0 Multiplier applied to the cooldown after a failed probe. Must be at least 1.0
breaker_cooldown_max_ms 300000 Ceiling for the escalating cooldown. Must be at least breaker_cooldown_ms

A value outside those rules is a step error, named on the call, not quietly adjusted into something workable. There is no way to switch the breaker off: if your flow must not use a fallback at all, refuse it explicitly with ctx.grpc_endpoint_index.

The state is per process and per (tenant, endpoint). It is lost on restart, which costs breaker_failure_threshold handshakes once — the same handshakes you pay on every call without it.

Declarative validation

If your .proto files use protoc-gen-validate, the constraints are evaluated before the request leaves. A violation is a step error naming the field and the rule, and it costs no network call — which matters when the provider meters them.

{"error":"grpc_egress: request violates the schema: `reference` violates `string.min_len = 8`: 4 characters","step":"query"}

The value that failed is never included in the message: these messages reach logs, and the data being validated is usually personal.

Evaluated today:

Rule set Rules
string len, min_len, max_len, pattern
repeated min_items, max_items, items
message required
int32, int64, uint32, uint64 gte, lte, gt, lt
enum defined_only

Lengths count characters, not bytes — matching PGV, which has separate *_bytes rules.

Rules on the elements of a repeated field go in repeated.items, as PGV defines them, and the violation names the element:

repeated string codes = 3 [(validate.rules).repeated = {min_items: 1, items: {string: {len: 3}}}];
{"error":"grpc_egress: request violates the schema: `codes[1]` violates `string.len = 3`: 6 characters","step":"query"}

Wrapper types are unwrapped first. A rule on a google.protobuf.StringValue or Int32Value field applies to the wrapped value, and message.required is what governs whether the wrapper itself must be present — again matching PGV. An absent wrapper is not validated; there is no value to constrain.

A rule outside that set makes nexus proto deploy refuse the descriptor, naming the field and the rule. That is deliberate: accepting it would mean the schema says a field is validated while the platform silently does not check it, and nobody goes looking for an enforcement they were promised. If you hit this, either drop the rule from the contract or ask for it to be implemented — do not expect it to be ignored.

Descriptors that use no validation annotations are unaffected.

Errors

A failed call produces a step error whose message carries the gRPC status code:

{"error":"grpc_egress: PermissionDenied","step":"fetch"}

The code travels; the server’s own error text does not, because it is written by the other end of the connection. The full text is in the operational log, where an operator can read it and a caller cannot. Route on the code, and add a ## Fault: section to decide what your caller sees.

Every call is recorded in the audit trail with the hash of the descriptor it was resolved against, on success and on failure alike.

Not supported

Client-streaming and bidirectional methods are refused, with an error naming the method: one step supplies one request message, and sending the first of several would let the server answer a truncated question.

Nothing warns you at call time that a certificate is close to expiring. Register it with nexus cert add and the platform will tell you how long it has left; the call itself only reads the file.