Skip to content

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-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
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-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.

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-order
effects: [grpc_egress]
endpoint: https://orders.example.com:443
proto: orders
service: acme.orders.v1.OrderService
method: GetOrder
bearer_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 is deadline_ms:, not grpc-timeout), content-type, user-agent, and the names http_egress refuses in headers:, traceparent and tracestate included.
  • authorization in metadata: together with bearer_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 0x20 to 0x7E). A line break, a non-ASCII character or an empty value is refused.
  • An entry whose name ends in -bin is 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_token must be a bearer token as RFC 6750 defines it: letters, digits and -._~+/, optionally followed by = padding. A value that starts with Bearer , 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-token
effects: [oauth2_token]
token_url: https://auth.example.com/oauth2/token
client_id: "{{ ctx.orders_client_id }}"
client_secret: "{{ ctx.orders_client_secret }}"
## Step: fetch-order
effects: [grpc_egress]
endpoint: https://orders.example.com:443
proto: orders
service: acme.orders.v1.OrderService
method: GetOrder
bearer_token: "{{ ctx.oauth2_token }}"

A token or key that does not expire is read with secret_read:

## Step: load-key
effects: [secret_read]
key: orders_key
## Step: fetch-order
effects: [grpc_egress]
endpoint: https://orders.example.com:443
proto: orders
service: acme.orders.v1.OrderService
method: GetOrder
metadata: {"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-key
effects: [secret_read]
key: door_key
## Step: call
effects: [grpc_egress]
endpoint: http://127.0.0.1:9091
proto: orders
service: acme.orders.v1.OrderService
method: GetOrder
bearer_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-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, 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: 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 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 key

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.

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 *_bytes rules 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.
  • NaN fails every range rule on a float or a double.
  • timestamp.lt_now, timestamp.gt_now and timestamp.within are 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; the required rules 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 NexusFabric

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. 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.