navree
Documentation

Request-response

Answer messages with #[message_pattern] handlers and call them with ServiceClient::send: payloads, validation, access, the unit of work, deadlines, errors across services and testing with MicroFake.

On this page

Introduction

Featuremicro Request-response is a call between services: one service sends a message and waits for the answer. Use it when the caller needs the result now, to read a deal, to check a quota, to move a deal and show the board that results. When the caller only needs to say that something happened, publish an event instead: nobody waits, and the receiving services can be down for a while.

A handler answers one pattern, a dotted name like deals.get. The caller names the pattern, sends a payload and gets back either the handler's result or its error, in Navree's JSON error shape.

Message handlers

Defining handlers

Handlers are methods of an #[injectable] struct, in an impl block marked #[message_handlers]. Each method marked #[message_pattern("...")] answers that pattern. These are the deals service's:

services/deals/src/handlers.rs

The method's name is yours; the pattern is what callers use. A handler is an async fn(&self, extractors..), like a route handler, and returns Result<T> where T is the reply.

Registering handlers

Register the struct in its module's register hook. The handlers then resolve their dependencies with the module's visibility, like its controllers:

services/deals/src/lib.rs

The boot fails, naming both handlers and their modules, when two handlers in one app answer the same pattern.

Patterns

A pattern is one or more lower-case segments separated by dots, each made of letters, digits, _ and -. A message pattern names exactly one handler, so it has no wildcards. Name patterns <service>.<action>: deals.get, deals.move, companies.get. The prefix tells a reader which service answers, and keeps two services from answering the same pattern, which the boot of one app can't detect.

A breaking change to a payload or a reply is a new pattern, such as deals.get.v2: keep the old handler until every caller has moved. See changing a payload.

The payload

Payload<T> decodes the message's payload into T, any type that implements serde::Deserialize. Keep payload types in the shared contract crate, so both sides compile against the same definition:

crates/crm-contracts/src/deals.rs

Validation

Valid<Payload<T>> runs the garde rules of T after decoding, exactly like Valid<Json<T>> on a route, with the same messages. A payload that breaks a rule never reaches the handler; the caller gets a 400 reply with every broken rule:

400 reply to deals.move

ValidAsync<Payload<T>> adds the database rules (unique, exists), and a payload that doesn't decode at all (a missing field, a wrong type) is a 400 too, with serde's message for the field. See validation for every rule.

Extractors

A handler takes any of these, in any order:

ExtractorGives you
Payload<T>The decoded payload.
Valid<Payload<T>>, ValidAsync<Payload<T>>The decoded and validated payload.
UowThe message's unit of work.
CurrentPrincipalThe authenticated caller (401 for anonymous messages).
CurrentUserThe caller's user record, loaded with navree-auth.
AbilitiesThe caller's abilities.
Accessible<E, A>A SQL filter for the records of E the caller may A.
Authorized<E, A>The record named by a #[can(.., from = "field")] check.
Scoped<T>A request-scoped provider, built once per message.
CurrentWorkspaceThe workspace the message was sent in, from its x-workspace header.
MessageContextThe pattern, the message id, the sending service, the deadline and the headers.

Every extractor that works on request parts works here, your own included, because a message reaches the handler as request parts: its headers are the message headers, and its extensions hold the app, the request id and the authentication state.

Rust

Credentials and access

Message patterns are deny by default, like routes. Each handler declares who may call it with #[public], #[authenticated], #[roles(..)], #[permissions(..)] or #[can(..)], on the method or on the impl block; a handler without one doesn't compile:

Text

The checks are the same as on routes, with one difference: #[can(.., from = "id")] reads the record's id from the payload field id (a top-level field holding a UUID, a string or an integer) instead of a path parameter. A missing record is a 404 reply and a refused one a 403, before the handler runs.

Forwarded credentials

When a service sends a message while it handles an HTTP request or another message, ServiceClient copies that request's Authorization header into the message. The receiving service authenticates it with its own dyn Authenticator, so the deals service sees the same user the API saw, and its access rules apply to that user.

For a JWT to verify everywhere, every service needs the signing secret: set the same auth.jwt.secret (APP_AUTH__JWT__SECRET) in each. Verifying a token needs no database. Roles, permissions and abilities do: navree-access reads them from its tables, so services that check them read the same tables (in the CRM, every service connects to the database that holds the identity tables).

Turn forwarding off with micro.forward_auth = false, or for one call with .without_auth().

Calls without a user

Jobs, scheduled tasks, event handlers and commands have no request to forward. Their messages are anonymous, so #[authenticated] handlers answer them with a 401. Two ways to allow them:

  • Act as a user. Pass a token yourself with .bearer(token), for example one signed for a system account with JwtService::sign_access(user_id). The handler then checks that account's roles as usual.
  • Mark the handler internal. Make it #[public] and protect it with a guard, so only your own services get through.

Guards

Guards are the same navree::http::Guard trait as on routes, attached with #[guard(..)] to a handler or to the impl block. This one lets a message through only with the key that every internal service is configured with:

services/deals/src/internal.rs

services.key is a setting of your app, not of Navree: any section name works.

Rust

The caller sets the header with .header("x-service-key", key). Guards run after authentication and before the access check, in the order they are written, and a refusal is the reply. When the app has an audit trail, a 403 from a guard or an access check is recorded as access.denied, with the message's pattern in place of the route.

The unit of work

A message handler gets a unit of work the way a request does. It is lazy, holds the caller's user id as its actor and the message's request id, and:

  • commits when the handler returns Ok: change observers run, the transaction commits, the after-commit hooks run, and then the reply is sent;
  • rolls back when the handler returns an error or panics, and the error is the reply.

The reply waits for the commit, so a caller never sees a result that is then rolled back. If the commit itself fails, the reply is the error the database mapping gives (a 409 for a unique or foreign-key violation, a 500 otherwise). Everything that joins a request's unit of work joins a message's: queued jobs, queued mail, notifications, and events emitted with client.emit, which go through the outbox.

A send is different: it calls another service at once and doesn't wait for your commit. If the deals service changes a deal and the caller's own unit of work then rolls back, the change in the deals service stays. Use send for reads and for commands whose failure you handle where you call them; to change several services together, have one service do its part and emit an event that the others react to.

Replies

A handler returns Result<T>, where T is any Serialize type: a struct, a Vec, an Option (None replies null), or () for a handler with nothing to say. The reply carries status 200 and the encoded value.

An error is a reply too. Return an AppError, or use ? on any error, and the reply carries the error's status and its JSON body. Database errors map as they do for routes: a missing record is a 404, a unique violation a 409. Errors with a 5xx status are logged on the handler's side with the request id; their message stays "Internal server error", so internals never cross the wire.

Sending requests

Inject Arc<ServiceClient>, the client of the app's transport, and call send with the pattern and a reference to the payload. The result is the reply, decoded into the type you ask for:

services/crm-api/src/deals/controller.rs

The API doesn't validate the move itself: the deals service does, and its 400, with the field errors, reaches the browser unchanged. Ask for serde_json::Value when you don't want a typed reply.

send returns a builder; .await sends the message. Before that, you can change the call:

MethodEffect
.timeout(duration)The deadline of this call, instead of micro.request_timeout_ms.
.retries(n)Retry up to n times on a timeout or a transport failure. See retries.
.bearer(token)Send these credentials instead of the forwarded ones.
.without_auth()Send no credentials.
.header(name, value)Add a header.

Navree adds the rest itself: a new message id, navree-service, the request id and the trace context of the current request, its Authorization (unless turned off), its workspace as x-workspace, and the deadline.

Calling several services at once

send is a future like any other. Run independent calls concurrently with tokio::try_join!; each keeps its own deadline:

Rust

Sending outside a request

Jobs, listeners and commands resolve the client from the app:

Rust

&() sends an empty payload, for a handler that takes none.

Timeouts and deadlines

Every request has a deadline: micro.request_timeout_ms (5 seconds) from the moment it is sent, or .timeout(..). It travels with the message as navree-deadline, and both sides respect it:

  • The caller stops waiting at the deadline and gets MicroError::Timeout.
  • The receiving service drops a message whose deadline has already passed without running the handler, since nobody is waiting for the answer. A handler still running at the deadline is cut off: its unit of work rolls back, and no reply is sent. A handler's #[timeout("2s")] sets a shorter limit of its own; a handler cut off by it rolls back and replies with a 503 whose detail says it didn't finish in time.
  • Calls made while handling a message inherit what is left of the deadline. If crm-api gives deals.move two seconds and the deals service spends 1.5 of them before it calls companies.get, that call gets the remaining half second, even if its own timeout is longer. A chain of calls can't outlive the request that started it.

In an HTTP handler, http.timeout_secs (or the route's #[timeout]) still bounds the whole request, so give a send a deadline shorter than the route's.

Errors across services

send returns Result<R, MicroError>. With ? in a handler, MicroModule maps each error to an AppError, so it reaches your client in the usual error shape:

MicroErrorWhenBecomes
Remote(RemoteError) with a 4xx statusThe handler refused or failed the requestThe same status, message and field errors
Remote(RemoteError) with a 5xx statusThe handler failed502 Bad Gateway
Timeout { pattern, after }No reply before the deadline504 Gateway Timeout
NoResponders { pattern }Nobody listens to the pattern (NATS, and transports that know)503 Service Unavailable
Transport(..)The broker is unreachable, or the connection broke503 Service Unavailable
Decode { pattern, .. }The reply doesn't decode into the type you asked for502 Bad Gateway
PayloadTooLarge { size, limit }The payload is over micro.max_message_bytes500 Internal Server Error
Encode { pattern, .. }, InvalidPattern { pattern, .. }The payload doesn't encode, or the pattern isn't a valid one500 Internal Server Error
ShutDownThe app is shutting down503 Service Unavailable

A 4xx passes through because it describes the user's request: a deal that doesn't exist is a 404 for the browser too. A 5xx doesn't, because the failure is between your services, not in what the user sent; the original error is logged by the service where it happened, under the same request id.

Match on the error when a failure has a meaning for you. Here a deal that doesn't exist, or that the user may not read, isn't shown:

Rust

RemoteError has status(), message() (the message, or the first of several), errors() (the field errors of a 400) and body() (the whole JSON).

Retries and idempotency

send doesn't retry by itself: a request that timed out may have run, and running it twice can be wrong. When it is safe, ask for retries:

Rust

A retry happens on a timeout, a transport failure or NoResponders, never on a reply (an error reply is an answer). Each attempt gets the full timeout, after a pause of 100 ms that doubles, and every attempt carries the same message id.

Reads are safe to retry. For a handler that changes data, let the receiving side remember the answer: with dedupe, Navree stores the reply under the message id in the inbox, inside the handler's transaction, and answers a repeated id with the stored reply instead of running the handler again:

Rust

Stored replies are kept for micro.inbox_retention_hours (72). dedupe needs DatabaseModule and micro.inbox (on by default with a database).

Scaling

All the instances of a service share its requests: each request goes to one of them. One process handles up to micro.concurrency messages at once, over every pattern. Limit a single pattern with the attribute's concurrency option, for a handler that is expensive or uses a scarce resource:

Rust

Requests over the limit wait in the broker, not in the process, so another instance can take them.

Testing

MicroFake gives the app an in-memory transport, sends messages into its handlers, answers the messages it sends to other services, and records everything:

Rust

Testing handlers

micro.client() sends into the app through the whole pipeline: authentication, guards, the access check, validation, the unit of work. acting_as authenticates the message as a test user, as TestApp::acting_as does for HTTP. request returns the reply for assertions, in the style of TestResponse:

services/deals/tests/move.rs

The reply assertions are the ones HTTP tests use: assert_status, assert_error, assert_validation_error and assert_json_path, plus json::<T>() to decode the reply.

Stubbing other services

Messages the app sends to patterns it doesn't handle itself go to the fake. Tell it how to answer with on; a pattern without an answer fails the call with MicroError::NoResponders, as on a broker where nobody listens:

services/crm-api/tests/deals.rs
AnswerBehaves like
Reply::json(&value)A handler that returned Ok(value).
Reply::error(app_error)A handler that returned the error.
Reply::from_fn(|payload: T| ...)A handler you write inline: it receives the decoded payload and returns Result<R, AppError>.
Reply::timeout()A service that never answers: the call times out at once, without waiting for the deadline.
Reply::sequence([..])Each answer in turn, then the last one again: for testing retries.

A later on for the same pattern replaces the earlier one. micro.sent::<T>(pattern) returns the decoded payloads sent to a pattern, micro.sent_envelopes(pattern) the whole messages (to check a header such as authorization or traceparent), and assert_nothing_sent() checks that nothing went out.