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-ordereffects: [grpc_egress]endpoint: https://orders.example.com:443proto: ordersservice: acme.orders.v1.OrderServicemethod: GetOrderStep 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-lookuptenant: acmeeffects: [grpc_egress]---
## Step: check```validate$.orderId != null | "orderId is required" | syntactic```
## Step: build-request```ntd{ "orderId": "{{ $.orderId }}" }```
## Step: fetcheffects: [grpc_egress]endpoint: https://orders.example.com:443proto: ordersservice: acme.orders.v1.OrderServicemethod: GetOrderdeadline_ms: 5000$ 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: searcheffects: [grpc_egress]endpoint: https://orders.example.com:443proto: ordersservice: acme.orders.v1.OrderServicemethod: SearchOrdersdeadline_ms: 60000max_stream_messages: 5000max_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-pageeffects: [grpc_egress]endpoint: https://orders.example.com:443proto: ordersservice: acme.orders.v1.OrderServicemethod: 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: fetcheffects: [grpc_egress]endpoint: https://orders.internal.example.com:443tls_ca_cert: /etc/nexus/pki/internal-ca.pemproto: ordersservice: acme.orders.v1.OrderServicemethod: GetOrderThe 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: queryeffects: [grpc_egress]endpoint: https://partner.example.com:443tls_ca_cert: /etc/nexus/pki/partner-ca.pemtls_client_cert: /etc/nexus/pki/our-client.crttls_client_key: /etc/nexus/pki/our-client.keyproto: ordersservice: acme.orders.v1.OrderServicemethod: GetOrderBoth 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: queryeffects: [grpc_egress]endpoint: https://primary.example.com:443, https://standby.example.com:443proto: ordersservice: acme.orders.v1.OrderServicemethod: GetThey 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```conditionctx.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.