Skip to content

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.

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)
end
end

Illustrative; the real modules are listed on the Dienstkarte. Start it under a supervisor:

children = [Redispatch.Service.MasterDataV2]

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.

Intended order, most specific wins, merged at compile time:

  1. per-function: @call_opts before a body-form defapi, or options on a delegate-form defapi
  2. module: options given to use NL.Cluster.Service
  3. 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.

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.

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.

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.

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: :get
end

Illustrative. to: is the service name as registered, not a module the client compiles against — no dependency on the provider is needed.

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.

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 defaults
Client.search(query, timeout: 5_000)
Client.search(query, retry: false) # one attempt only
Client.search(query, retry: [after: 100, limit: 1_000])

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.

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 ->
result
end

No catch-all is added; an unmatched result raises CaseClauseError.

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.