Dienste und Clients
A Dienst (service) is a module on the providing side whose functions other nodes may call. A Client is a module on the consuming side with one stub per remote function. The two never share code: the client names the service by its registered name and version, and the registry fills in the rest at call time.
Defining a service
Section titled “Defining a service”defmodule Redispatch.Service.MasterDataV2 do use NL.Cluster.Service, name: Redispatch.Service.MasterDataV2, version: "2.2.0", timeout: 30_000, # optional: call defaults for all functions retry: [after: 5_000, limit: 60_000] # optional
# delegate form, like defdelegate defapi get_sr(id, opts), to: Redispatch.MasterData, as: :fetch_sr
# body form, like def — per-function overrides via @call_opts @call_opts [timeout: 120_000] defapi all_sr_ids_by_source(source, opts) do Redispatch.MasterData.sr_ids(source, opts) endendIllustrative; the real modules are listed on the Dienstkarte. Start it under a supervisor:
children = [Redispatch.Service.MasterDataV2]use options
Section titled “use options”netzlive.cluster/lib/nl/cluster/service.ex:169-221.
| Option | Required | Meaning |
|---|---|---|
name: |
yes | Registry key. An atom; usually a module alias, but nothing requires a module of that name to exist. Clients use exactly this value as to:. |
version: |
yes | SemVer string. The NL.Cluster.Service struct defaults it to "1.0.0", but use makes it required. |
timeout:, retry: |
no | Call defaults for every function in the module. See Ein Aufruf. |
The compat: field of the struct exists but is not settable through use — it only means
something on the client side.
Precedence of call options
Section titled “Precedence of call options”Intended order, most specific wins, merged at compile time:
- per-function:
@call_optsbefore a body-formdefapi, or options on a delegate-formdefapi - module: options given to
use NL.Cluster.Service - library defaults (
rpc_timeout,rpc_retry_after,rpc_retry_limit)
The merged result is stored per function in the service struct and travels through the registry. On top of that, a caller can still override per call — see below.
@call_opts is reset to nil after each body-form defapi (netzlive.cluster/lib/nl/cluster/service.ex:344),
so it applies to exactly one function.
What defapi does to your function
Section titled “What defapi does to your function”Both forms generate a function that runs your code inside try … catch and passes the
outcome to __handle_service_result__/3 (netzlive.cluster/lib/nl/cluster/service.ex:418-452):
| Your code | Generated function returns | Reported to error_reporter? |
|---|---|---|
returns {:error, %SomeException{}} |
{:ok, {:error, %ServiceError{original: %SomeException{}}}} |
no |
| returns anything else | {:ok, value} |
no |
| raises / exits / throws an exception struct | {:error, %ServiceError{reason: …}} built via NL.Cluster.Error |
yes |
| exits / throws a non-exception term | {:error, %ServiceError{reason: :unhandled_exit | :unhandled_throw, original: term}} |
yes |
The outer tuple says whether the function ran to completion; the inner value is whatever it returned. The client unpacks both — see result mapping.
Crashes are reported on the provider node via the configured error_reporter
(netzlive.cluster/lib/nl/cluster/service.ex:426-434). Returned errors are not reported anywhere — they are
part of the function’s contract.
Errors across the node border
Section titled “Errors across the node border”An exception struct only renders on a node that has its module loaded. The client usually
does not. So before an exception leaves the provider it is converted through the
NL.Cluster.Error protocol (netzlive.cluster/lib/nl/cluster/error.ex). The default Any implementation
(:62-77) builds:
%NL.Cluster.Errors.ServiceError{ reason: :unhandled_exception, message: Exception.message(original), # rendered on the provider original: original # the struct, still there for clients that know it}plus service, service_function and stacktrace. Implement the protocol for an
exception to control reason, message and meta yourself; the moduledoc has an
example.
Services as a separate dependency
Section titled “Services as a separate dependency”The legacy convention, from Netzlive.Cluster’s moduledoc: module name
{App}.Service.{Name}V{major}, one module per major version, and return maps, not
structs, because the client may not have the struct’s module. The last rule still holds
for NL.Cluster: a struct in a return value arrives on the client as a struct of a module
that may not exist there. It can be pattern-matched on fields, but any function that
dispatches on it — Exception.message/1, a protocol — fails.
Defining a client
Section titled “Defining a client”defmodule Redispatch.DrivenAdapters.NmkSearchServiceAdapter do use NL.Cluster.Client
defdelegate_api search(query), to: NMK.Service.Search, version: "3.0.0" defdelegate_api fetch(id), to: NMK.Service.Search, version: "3.0.0", as: :getendIllustrative. to: is the service name as registered, not a module the client
compiles against — no dependency on the provider is needed.
Options
Section titled “Options”netzlive.cluster/lib/nl/cluster/client.ex:58-82, validated with NimbleOptions at compile time since 2.10
(unknown keys raise).
| Option | Meaning |
|---|---|
to: |
Service name. Required. |
version: |
Version the client was written against. Defaults to "1.0.0" — always set it. |
compat: |
:semver (default: same major) or a Version requirement string. See Registry → Lookup. |
as: |
Remote function name if it differs from the local one. |
unwrap: |
Exception module(s) to unwrap from returned ServiceErrors. Since 2.10. |
Options given to use NL.Cluster.Client are merged under each defdelegate_api’s own
(:192-197), so a module can set version: or unwrap: once.
The extra argument
Section titled “The extra argument”Every generated function gets one more optional argument at the end, for per-call options
(netzlive.cluster/lib/nl/cluster/client.ex:207-212):
Client.search(query) # registry defaultsClient.search(query, timeout: 5_000)Client.search(query, retry: false) # one attempt onlyClient.search(query, retry: [after: 100, limit: 1_000])What the caller gets back
Section titled “What the caller gets back”netzlive.cluster/lib/nl/cluster/client.ex:365-371. Given that the provider’s function returned x:
Provider returned x = |
Client returns |
|---|---|
:ok |
:ok |
{:ok, v} |
{:ok, v} |
{:error, e} |
{:error, e} — e wrapped in ServiceError if it was an exception |
anything else ([1, 2], nil, :error, {:ok, a, b}) |
{:ok, x} |
And independent of x:
| Situation | Client |
|---|---|
| no service registered under that name | returns {:error, %ServiceError{reason: :noservice}} |
| registered, but no matching version | returns {:error, %ServiceError{reason: :noversion}} |
| chosen version lacks the function/arity | returns {:error, %ServiceError{reason: :no_service_function}} |
| provider function crashed | raises ServiceError |
transport failed after retries (timeout, :noconnection, …) |
raises RPCError |
The first three are returned, not raised (:285-299, netzlive.cluster/lib/nl/cluster/service.ex:143-162).
They are also never retried.
unwrap: and the do block
Section titled “unwrap: and the do block”Since 2.10. With unwrap: [MyApp.QueryError], a result
{:error, %ServiceError{original: %MyApp.QueryError{} = e}} comes back as {:error, e}
(unwrap_original/2, netzlive.cluster/lib/nl/cluster/client.ex:323-327). Everything else — including
:noservice, and the raise paths — is untouched. A per-delegate unwrap: [] switches it off
again.
A do block maps the result through case clauses; unwrapping runs first
(:233-257):
defdelegate_api query(filters), to: Provider.Example, version: "1.0.0" do {:error, %ServiceError{reason: reason}} when reason in [:noservice, :noversion] -> {:error, :unavailable} result -> resultendNo catch-all is added; an unmatched result raises CaseClauseError.
Calling without a client module
Section titled “Calling without a client module”NL.Cluster.call(service, function, args, opts) (netzlive.cluster/lib/nl/cluster.ex:49) takes an
%NL.Cluster.Service{} struct; a client module is just a compile-time way of building one
per function.