Skip to content

Client Recovery and Limits

Kevin Herron edited this page Oct 12, 2026 · 10 revisions

Milo 1.2.0-RC1, Java 17. Source baseline 69cb25087804.

A client that runs for weeks sees the network drop, the server restart or rotate its certificate, and requests that are too large for a production server. This page shows what Milo handles on its own in each case and what your application decides. It builds on the secured client from Security, set up as in Connecting with subscriptions from Subscriptions.

What Milo recovers and what you decide

When a connection fails, Milo recovers it in the background, in stages:

  1. The transport reconnect loop reestablishes the transport and SecureChannel.
  2. The client tries to reactivate its existing Session on the new channel.
  3. If that fails, the client creates a Session and tries to transfer subscriptions to it.

Each stage can fail on its own, so a new TCP connection does not prove that authentication, Session activation, or notification delivery has recovered. Watch the stage your application depends on.

Recovery cannot tell whether a write whose response was lost reached the server, whether a gap in notifications matters, or when the application wants to stop. Keep those decisions in application code, separate from recovery. Request sizing splits the same way. Milo learns the limits the server advertises, and your application sizes each request for the current server, Session, and payload.

Survive a connection loss

Detect the loss

Besides noticing a closed transport, the client sends periodic keep-alive requests so it can detect a connection that died silently. These client configuration settings control how quickly it gives up on the current connection.

Setting Default Meaning
setKeepAliveInterval(...) 5,000 ms Time between consecutive keep-alive requests
setKeepAliveTimeout(...) 5,000 ms How long the client waits for each keep-alive response
setKeepAliveFailuresAllowed(...) 1 Consecutive keep-alive failures tolerated before the connection counts as lost

To follow the Session itself, register a listener with client.addSessionActivityListener(...). Its onSessionActive and onSessionInactive callbacks tell you when to pause and resume work that needs a Session. They say nothing about notification continuity, so watch your subscriptions separately.

Recreate subscriptions the server lost

Session recovery tries to keep your subscriptions, either on the reactivated Session or by transferring them to a new one. When that fails, onTransferFailed reports that the Subscription could not move to the new Session, and onStatusChanged with Bad_Timeout reports that it timed out and no longer exists on the server. In both cases Milo resets the Subscription's server state but keeps the MonitoredItems you added, so your application recreates the Subscription and synchronizes the same desired items again.

This listener hands that work to an application executor. The callbacks run on the Subscription's delivery queue, which uses the transport executor, so a blocking create() there stalls notification delivery and can deadlock waiting for its own response.

static void recreateWhenLost(OpcUaSubscription subscription, Executor recoveryExecutor) {
  subscription.setSubscriptionListener(
      new OpcUaSubscription.SubscriptionListener() {
        @Override
        public void onTransferFailed(OpcUaSubscription lostSubscription, StatusCode status) {
          recoveryExecutor.execute(() -> recreate(lostSubscription));
        }

        @Override
        public void onStatusChanged(OpcUaSubscription changedSubscription, StatusCode status) {
          if (status.value() == StatusCodes.Bad_Timeout) {
            recoveryExecutor.execute(() -> recreate(changedSubscription));
          }
        }

        @Override
        public void onNotificationDataLost(OpcUaSubscription affectedSubscription) {
          LOGGER.warn("Notifications were lost. Reconcile from current values.");
        }
      });
}

static void recreate(OpcUaSubscription subscription) {
  try {
    // The client kept the MonitoredItems, so synchronizing creates them on the server again.
    subscription.create();
    subscription.synchronizeMonitoredItems();
  } catch (UaException e) {
    LOGGER.warn("Recreating the subscription failed", e);
  }
}

synchronizeMonitoredItems() creates the retained items on the server again. If you use triggering, rebuild the links afterward, because they depend on server-assigned IDs that change with the new Subscription. The example only logs a failed attempt, where a production client would schedule another.

onNotificationDataLost means Republish could not recover all missing data. The values you hold may be stale, so decide whether to reconcile from current values, backfill from history, or mark an explicit gap.

Decide what to retry

Milo does not replay every in-flight request after a reconnect, and a lost response does not mean the server skipped the operation. A Read is safe to send again. A write or method call may already have run, so retrying it needs an application-specific decision about side effects and duplicate execution. Calling the tutorial's AdjustSetpoint twice with the same Delta, for example, moves the setpoint twice.

Serialize recovery with shutdown, so a background handler cannot recreate subscriptions after the application decides to stop. With the listener above, shut down the recovery executor and wait for it to finish before you delete the Subscription.

Refresh an endpoint after certificate rotation

When a server replaces its certificate, a client holding the old endpoint description can no longer open a secured channel. An EndpointResolver fixes that. After a qualifying SecureChannel establishment failure on a secured endpoint, it rediscovers the server's endpoints and reselects one. The URL-based discovery factory installs a resolver, and a manually assembled configuration can supply one with setEndpointResolver(...). Plain TCP connection and Hello/Acknowledge failures do not trigger certificate rediscovery.

A refreshed endpoint must keep the selected URL, server application URI, transport, security policy and mode, and user-token policies. Only a newly advertised server certificate is expected to change. To make that hold, keep the unmodified discovery list alongside the selected endpoint, and apply any host override inside the endpoint selection function so every refresh selects the same overridden URL. Address configuration shows where that function lives.

Discovery is unauthenticated, so a refresh can neither downgrade the endpoint nor grant trust to a new certificate. Existing SecureChannels and Sessions keep their established certificate bindings, and a new channel alone does not rewrite them. The application and its provisioning process own trust-list updates and certificate rollout.

A timeout and a cooldown bound each resolution, and at most one resolution runs at a time. A custom resolver must return promptly, support cancellation, and release its resources. Disconnect cancels an outstanding resolution and ignores late results.

Size requests for the server

Operation limits

Servers advertise how many operations they accept in one request, such as MaxNodesPerRead. client.getOperationLimits() reads those limits and caches them per Session activation. To clamp an advertised limit or supply one the server omits, call setOperationLimitOverrides(Map<OperationLimit, UInteger>) on the client configuration builder, for example with Map.of(OperationLimit.MaxNodesPerRead, uint(500)). Milo combines the two values into an effective limit:

  • A zero override leaves the advertised value unchanged.
  • A server value of zero means no advertised limit.
  • With two nonzero values, the smaller wins.

SDK consumers that partition operations, such as type discovery and MonitoredItem batching, use these effective limits. The bulk service methods on OpcUaClient do not partition arbitrary input lists, though, so your application partitions its own Read, Write, Call, and other batches and keeps each operation's result. Overrides change local request sizing only. They do not change the server's capacity or the limits it advertises.

Timeouts

Choose request deadlines, transport connect and handshake timeouts, and application wait budgets to fit each operation and its failure handling. An application deadline ends your wait but does not cancel server-side work, so record the stage and OPC UA status before you retry.

Partition application reads

This helper splits a long list of NodeIds into Reads that fit both a size you choose and the server's effective MaxNodesPerRead. It keeps bad per-node DataValue results in input order instead of aborting a partially successful batch, so the caller inspects each status.

static List<DataValue> readInBatches(OpcUaClient client, List<NodeId> nodeIds, int maxBatchSize)
    throws UaException {
  if (maxBatchSize < 1) {
    throw new IllegalArgumentException("maxBatchSize must be positive");
  }

  // A server limit of 0 means the server advertises no limit.
  long serverLimit =
      client.getOperationLimits().maxNodesPerRead().map(UInteger::longValue).orElse(0L);

  int batchSize = maxBatchSize;
  if (serverLimit > 0 && serverLimit < batchSize) {
    batchSize = (int) serverLimit;
  }

  List<DataValue> values = new ArrayList<>();
  for (int start = 0; start < nodeIds.size(); start += batchSize) {
    int end = Math.min(start + batchSize, nodeIds.size());
    List<NodeId> batch = nodeIds.subList(start, end);
    values.addAll(client.readValues(0.0, TimestampsToReturn.Both, batch));
  }
  return values;
}

If a server advertises MaxNodesPerRead=1, a cap of 10 still produces one-node batches, while an unpartitioned three-node Read to that server fails with Bad_TooManyOperations. An empty input sends no Read for the requested nodes. The limits lookup before the loop can still read the server's OperationLimits on first use after Session activation or a cache reset, and that read can fail.

Against the tutorial server, a caller resolves the thermostat's NodeIds the way Your first client does and passes them in.

static List<DataValue> readThermostat(OpcUaClient client) throws UaException {
  UShort namespaceIndex = client.getNamespaceTable().getIndex("urn:eclipse:milo:wiki");
  if (namespaceIndex == null) {
    throw new IllegalStateException("Server does not expose the tutorial namespace");
  }

  NodeId temperatureId = new NodeId(namespaceIndex, "Temperature");
  NodeId setpointId = new NodeId(namespaceIndex, "Setpoint");
  List<NodeId> nodeIds = List.of(temperatureId, setpointId);

  return readInBatches(client, nodeIds, 500);
}

Two nodes fit in one Read, so you get Good 21.5 for Temperature and then the current setpoint, in input order. A list of thousands of tags goes through the same call in batches of at most 500.

The helper rejects a nonpositive cap. A service failure throws and returns no partial list, so an importer that must keep completed batches should process each one durably before it continues.

Node counts are only one limit. Large arrays, structures, diagnostics, and event fields can exceed message size or memory budgets even in a one-node request, so bound bytes, outstanding futures, continuation points, and queued notifications separately. Shrinking a request after Bad_TooManyOperations or an encoding-size error can help.

When things go wrong

Signal What it means What to do
onSessionInactive The Session left the active state, during recovery or a disconnect Pause work that needs a Session
onTransferFailed, or onStatusChanged with Bad_Timeout The Subscription did not move to the new Session or timed out on the server, and Milo reset it Recreate it, synchronize its items, and rebuild triggering links
onNotificationDataLost Republish could not recover all missing data Reconcile from current values, backfill history, or mark a gap
No response to a write or method call The operation may or may not have run Decide per operation whether a retry is safe
Bad_TooManyOperations The request exceeds a server operation limit Partition the request or lower the batch size
Encoding-size error The message exceeds a size limit Shrink the request and bound bytes, not only node counts

Record partial failures and dropped data as you handle them. Reconnection does not establish historical completeness or external device state.

Stop deliberately

Recovery keeps running until you stop it. A failed initial connect() or connectAsync() still starts background recovery, so call disconnect() or disconnectAsync() even then.

At shutdown, stop the things that can restart work before you release what they use:

  1. Cancel application retry tasks and stop the recovery executor.
  2. Delete subscriptions where the server is reachable.
  3. Disconnect the client.
  4. Close custom resolver resources.
  5. Stop application-owned executors and stores after their users have stopped.

Examples and reference

Next steps

Your client now survives connection loss and sizes its requests for the server. To read values the server stored in the past instead of current ones, continue with Historical access.

Related: Client SDK · Connecting · Subscriptions · Troubleshooting

Clone this wiki locally