Outbound 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 |
bearer_token |
— | Sent as authorization: Bearer <value> — see Call metadata and bearer token |
metadata |
— | Metadata entries sent with the request, an inline JSON object — see Call metadata and bearer token |
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.
Call metadata and bearer token
Most gRPC services want something besides the message: a token, an API key, a tenant or trace identifier. They travel as metadata, the request headers of gRPC. Two keys send them.
## Step: fetch-ordereffects: [grpc_egress]endpoint: https://orders.example.com:443proto: ordersservice: acme.orders.v1.OrderServicemethod: GetOrderbearer_token: "{{ ctx.oauth2_token }}"metadata: {"x-request-source": "nexus", "x-tenant": "{{ ctx.tenant }}", "trace-bin": "AAEC/w=="}bearer_token: is sent as authorization: Bearer <value>, the same key as on
http_egress. Write the token alone, without the
word Bearer.
metadata: has the form of headers: on http_egress: an inline JSON object, each value a
string or an array of strings. An array sends one entry per string, in the order written. Every
string can be a template.
Every endpoint of a cascade receives the same entries, on unary and server-streaming calls alike.
Names
Checked when the flow is published, by nexus validate, nexus compile and nexus deploy:
- A name is lowercased, then must contain only
0-9,a-z,_,.and-. - Two names that are equal once lowercased are refused.
- Names that belong to the transport are refused: any name starting with
grpc-(the call’s deadline isdeadline_ms:, notgrpc-timeout),content-type,user-agent, and the nameshttp_egressrefuses inheaders:,traceparentandtracestateincluded. authorizationinmetadata:together withbearer_token:is refused. Use one or the other.
Values
Checked on the written text when the flow is published, and on the rendered value before anything is sent:
- An ordinary entry must be non-empty printable ASCII (bytes
0x20to0x7E). A line break, a non-ASCII character or an empty value is refused. - An entry whose name ends in
-binis binary metadata. Write its value as base64 (standard alphabet, padded or not). The platform decodes it and sends the bytes. Text that is not base64, or is empty, is refused. bearer_tokenmust be a bearer token as RFC 6750 defines it: letters, digits and-._~+/, optionally followed by=padding. A value that starts withBearer, contains a space or a line break, or is empty is refused.
When a rendered value is refused, the step fails before the first endpoint is tried, so no
endpoint receives anything. The error names the key (bearer_token or metadata.<name>) and the
rule. It never quotes the value. Neither the bearer token nor any metadata: value is written to
the operational log, the event log or the audit trail.
A value whose template reads a ctx path that does not exist is left out with a warning, and the
other entries are sent.
An environment variable written directly in bearer_token: or in a metadata: value is not read
on grpc_egress; it is refused at publication. What a flow may read from the environment, and through which mechanism, is in
What a flow may read. Read the value into ctx first, with secret_read or a
config: value.
Where the token comes from
A token that expires comes from an oauth2_token step before the
call. That step caches the token and fetches a new one before it expires:
## Step: get-tokeneffects: [oauth2_token]token_url: https://auth.example.com/oauth2/tokenclient_id: "{{ ctx.orders_client_id }}"client_secret: "{{ ctx.orders_client_secret }}"
## Step: fetch-ordereffects: [grpc_egress]endpoint: https://orders.example.com:443proto: ordersservice: acme.orders.v1.OrderServicemethod: GetOrderbearer_token: "{{ ctx.oauth2_token }}"A token or key that does not expire is read with secret_read:
## Step: load-keyeffects: [secret_read]key: orders_key
## Step: fetch-ordereffects: [grpc_egress]endpoint: https://orders.example.com:443proto: ordersservice: acme.orders.v1.OrderServicemethod: GetOrdermetadata: {"x-api-key": "{{ ctx.orders_key }}"}Calling the platform’s own gRPC door
A flow can call a flow published on the same installation through its
gRPC door. The door admits it as any other caller:
with an API key of the routed flow’s tenant, or an OIDC token of an issuer that tenant declares.
The flow presents it as bearer_token::
## Step: door-keyeffects: [secret_read]key: door_key
## Step: calleffects: [grpc_egress]endpoint: http://127.0.0.1:9091proto: ordersservice: acme.orders.v1.OrderServicemethod: GetOrderbearer_token: "{{ ctx.door_key }}"Without the token the door answers Unauthenticated, and the routed flow does not run.
Cleartext
A bearer token, or an authorization entry, on an http:// endpoint is sent, and it travels in
the clear. RFC 6750 §5.3 requires TLS on the network path of a bearer token. Use http:// only
to a local sidecar or mesh proxy that adds TLS, or to the platform’s own door on the same host.
Response metadata
A gRPC server sends metadata in two places: initial metadata, in the header frame before the first message, and trailing metadata, in the trailer frame after the last. Providers put continuation tokens and attachment references in either, and both are easy to miss because they are not in the body. Each has its own variable, an object with lowercase keys:
| Variable | Holds |
|---|---|
ctx.grpc_headers |
the header frame, and nothing else |
ctx.grpc_trailers |
the trailer frame, and nothing else |
This holds on unary and on server-streaming calls alike. A key the server sends in both frames appears in both variables, each with its own value; the two are never merged. Read a token from the frame your provider’s contract names:
## 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, true of both variables. Transport control and credential keys —
grpc-status, grpc-message, set-cookie, authorization and similar — are filtered out and
never reach the flow; ordinary metadata such as content-type does. And both variables are
written on every path, including failure, so a failed call leaves two empty objects rather
than the previous call’s metadata.
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 refused at publication, by nexus validate and
nexus deploy alike, rather than falling 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:
step 'call': `tls_client_cert` is set without `tls_client_key` — a certificate cannot be presented without its private keyLike 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.
A queued message whose endpoints are all skipped before any effect of the flow ran is deferred
without spending an attempt, as on http_egress — see
Circuit breaker.
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. A map key is data too, so a rule on the keys or the values
of a map names field.key or field.value, never the key itself. A rule’s operand is quoted when
it is a number, a duration, a timestamp or a pattern (duration.lte = 30s); any other text, bytes
or list operand is not (string.prefix, int32.in).
Every rule of protoc-gen-validate’s validate.proto is evaluated except map.no_sparse, as
the copy inside your descriptor set defines it:
| Declared on | Rules |
|---|---|
| a field | the 21 rule sets: float, double, int32, int64, uint32, uint64, sint32, sint64, fixed32, fixed64, sfixed32, sfixed64, bool, string, bytes, enum, repeated, map, any, duration, timestamp, each with all of its rules but map.no_sparse; and message (required, skip) |
| a message | (validate.disabled) and (validate.ignored): no rule of that message is evaluated |
| a oneof | (validate.required): one member of the oneof must be set |
The rules behave as PGV defines them:
- Lengths count characters, not bytes; the
*_bytesrules count bytes. - A range whose lower bound is above its upper bound (
gt: 10, lt: 5) is an exclusive range: the value must lie outside it. NaNfails every range rule on afloator adouble.timestamp.lt_now,timestamp.gt_nowandtimestamp.withinare judged against the platform’s clock at the moment of the call.- An unset plain proto3 scalar reads as its default value and is checked like any other value,
unless the rule set declares
ignore_empty. A field that has presence (optional, a message, a oneof member) is checked only when it is set; therequiredrules are the ones that say it must be.
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. Such a rule comes
from a validate.proto newer than the platform, or from an edited copy of it. map.no_sparse is
refused at nexus proto deploy too, although the unedited validate.proto declares it: the
platform never sends a map entry without a value, so the rule could never refuse a request, and
removing it from the contract changes no verdict. The descriptor is not stored, the command exits
with a non-zero status, and every such rule gets one line on standard error naming the message,
the field and the rule:
message `shop.v1.OrderRequest`, field `contact`: `(validate.rules).string.new_rule` is not evaluated by NexusFabricThat 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. A call against such a descriptor fails the same way, with a step error, instead of sending the request unchecked. 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.
Only protoc-gen-validate’s annotations are read. Constraints written for its successor,
protovalidate (buf.validate), are not evaluated. Descriptors that use no validation annotations
are unaffected.
Errors
A unary method must answer with exactly one message. An answer with none, or with more than one,
fails the step with Internal and a text naming the violation; a second message is refused as
soon as it arrives, without reading further. deadline_ms covers the whole unary answer, body and
trailers included, as it covers a stream.
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.