Skip to content

ADR 0088: A service fault is an answer, and the OPC UA client library is carried on a fork until upstream takes the fix

Status: Accepted Date: 2026-09-03 Issue: #1929 Related: #1926 (where the coin toss was measured), #1743 (the health verdict a reconnect flaps), #1780 (ADR 0081, the same rule on the Modbus side), gopcua/opcua#904 (the fix, upstream)

Context

Every OPC UA session the product opens goes through gopcua. Its client reads every response whose header carries a status other than Good as a loss of the channel: the dispatcher hands the status to the reconnect monitor before it hands the response to the request's handler, and the monitor, on by default, closes the socket and dials again. The caller whose request drew the fault is still on its way to select between its answer and the disconnect, and reads whichever Go picks. Under #1926 that was EOF about one run in a hundred and fifty, against an in-process server nothing had disconnected.

A service-level fault is the ordinary way a server refuses one request on an open channel: Bad_TooManyOperations for a request too large, Bad_ServiceUnsupported from a controller lacking a service set, Bad_UserAccessDenied. On the long-lived driver connection each of those tore the secure channel down, paused and republished every subscription on it, flipped the connection state the health verdict of ADR 0075 reads, and could hand the caller EOF where the fault itself was the answer. ADR 0081 had already settled the same question for Modbus: a refusal is not a lost connection. Here the confusion was inside the library, three releases deep, and unchanged in gopcua v0.9.1 and on its main branch.

Decision

A service fault reaches the caller as the status it was, and the channel it came on stays up. Two halves, one per client package.

The per-call sessions in pkg/opcuaclient pass AutoReconnect to gopcua explicitly in both directions. Those sessions are:

  • CALL_SERVICE in the procedural operator
  • the gateway's discovery and its session pool
  • the dcs opcua subcommands

gopcua's default is on, so a false that was not passed was never off. With it off the monitor exits without touching the socket. What off costs is the publish loop. The one session in that package that subscribes is dcs opcua subscribe, and it keeps reconnect on.

The long-lived driver in pkg/opcua needs reconnect for a real drop, so its half is a change to gopcua itself: the dispatcher records whether an error came from a response header, and forwards one to the monitor only when the status says the channel or the session is gone (Bad_SecureChannelIdInvalid, Bad_SecureChannelClosed, Bad_SecureChannelTokenUnknown, Bad_SessionIdInvalid, Bad_SessionClosed, Bad_SessionNotActivated, Bad_SubscriptionIdInvalid, Bad_CertificateInvalid, Bad_ConnectionClosed). Transport, decoding and UACP ERR errors reach the monitor as before. The fix lives in the dispatcher and not in the monitor. The monitor cannot tell a header status from a UACP ERR status, and an ERR is a channel-level message the server closes the socket after.

The library is carried on a fork under the organisation, and the fix travels upstream from it. github.com/cloud-native-dcs/opcua is a fork of gopcua. Branch cndcs/service-fault-v0.8.0 is v0.8.0 plus the one commit, tagged v0.8.0-cndcs.1, and go.mod carries a replace to that tag. The same commit is proposed upstream as gopcua/opcua#904 from the fork's service-fault branch. When upstream releases it, the replace goes and the requirement moves to that release. Until then a gopcua bump is a rebase of the fork branch and a new -cndcs.N tag. It is never a bump of the require line alone, because the replace would override that in silence. Dependabot is told to leave gopcua alone for the same reason.

Alternatives Considered

  • Reconnect off in the driver as well. Rejected. The driver holds subscriptions for the life of the unit runtime, and gopcua ends the publish loop when its monitor exits. A field-side blip would then end every subscription in silence, which is worse than the flap it cures.
  • A reconnect loop of our own around a client with reconnect off. Rejected. On a service fault the client's monitor still exits and still ends the publish loop, so the driver would have to rebuild the whole client on every fault. That is the same disruption with a different owner.
  • Patching the monitor to skip more statuses. Rejected. The monitor sees one error channel and cannot tell a response header from a UACP ERR message, and an ERR carrying a channel-level status is a real loss. The dispatcher knows where the error came from.
  • Bumping to gopcua v0.9.1. Not sufficient. v0.9.1 and gopcua's main branch carry the same monitor and the same dispatcher.
  • Vendoring a patched copy in the tree. Rejected. Every tree-walking gate and generator would have to learn to skip it, and #985 is what happens when one does not.
  • Waiting for upstream. Rejected as the only road. The pull request is open, but nothing upstream owns the defect today, and the coin toss is in the product now.

Consequences

  • A Bad_TooManyOperations on the driver connection is now one failed request. The subscription beside it keeps publishing, the connection state does not move, and the health verdict does not flap.
  • pkg/opcuaclient callers read the fault they were sent. A caller that had learned to retry on EOF after a Call can stop.
  • The fork is one more artefact to keep: the branch, the tag and the replace. Its whole content is one commit on a tagged upstream release, so a reviewer can diff it in a minute.
  • The gopcua require line still names an upstream version, and it is the version the fork branch sits on. The two must agree, and the fork is where a bump starts.

Proof

  • pkg/opcuaclient/fault_server_test.go draws Bad_TooManyOperations on a Call through a counting TCP relay and holds the caller reading that status, the same session answering a Read, and the relay having accepted nothing new. It fails three of three on the client as it was. A control with Bad_SecureChannelIdInvalid and reconnect on shows the count can move.
  • pkg/opcua/fault_test.go does the same on the driver across a Browse, with a subscription held open through the fault and a value written afterwards coming back through it. It fails three of three on gopcua v0.8.0 as shipped and passes on the fork.
  • uasc/service_fault_test.go on the fork covers the classification.