Files
Klaus Landsdorf cc7908eaa1 WebApi: serve a generated OpenAPI document of the REST binding (opt-in) and map the session-less service set (#4657)
Reworked after the review: the OpenAPI document is generated from the
code, not embedded; the route is opt-in; `StatusCode` is not accepted.
The branch is a new history on top of master (the first version
conflicted); master with #4674 is merged in.

# Description

The REST binding implements the routes of the OPC Foundation OpenAPI
mapping (OPC 10000-6 §G.3), but it does not describe them: `GET
/openapi.json` is answered with HTTP 405, and a client generator has
nothing to read from the server. `docs/WebApi.md` listed a generated
OpenAPI document as a deferred follow-up.

The coded API is the source of truth, so this PR **generates** the
document from the route table and from the structure definitions of the
request and response types, and compares it with the document the model
compiler publishes in tests. Nothing from `UA-Nodeset/OpenApi` ships in
the package.

## Changes

### Service sets (`Opc.Ua.Core`)

- `WebApiServiceSet` (`AllServices`, `Sessionless`) and
`WebApiServiceRoutes.GetRoutes(serviceSet)`. `Sessionless` is the eight
services of `opc.ua.openapi.sessionless.json` (OPC 10000-4 §6.3): Read,
Write, HistoryRead, HistoryUpdate, Call, Browse, BrowseNext,
TranslateBrowsePathsToNodeIds.

### Generator (`Opc.Ua.Core.Schema`, `Opc.Ua.Schema.OpenApi`)

- `WebApiOpenApiGenerator.Generate(serviceSet, includeSchemas,
serverUrl)` returns the OpenAPI 3.0 document as a `JsonObject`. It is
built at runtime from `WebApiServiceRoutes` and the
`StructureDefinition`/`EnumDefinition` of the message types (the same
definitions the schema generation uses, resolved through
`IDataTypeDefinitionResolver`). No reflection, so it is NativeAOT
compatible; the AOT test project covers it.
- **Two levels of detail**, because full schemas make big documents:

  | | Content | All 28 services |
  | --- | --- | --- |
| default | paths, `operationId`s, every body a JSON object | about 12
KB |
| `includeSchemas: true` | one component schema per message, structure
and enumeration (100), for typed client generators | about 46 KB |

  The two published files are about 300 KB together.
- The schemas describe the Compact JSON encoding, with the property
names, types, formats and integer ranges of the published documents.
DataValue is the one structure that differs: its status property is
`Status`, as in Part 6 Table 42.
- `OpenApiBuiltInSchemas` holds the schemas of the built-in types
(StatusCode, LocalizedText, Variant, DataValue, DiagnosticInfo,
ExtensionObject and the scalar types) as the encoder writes them.
Abstract `Structure` fields are ExtensionObjects, abstract number fields
are Variants.

### Serving the document (`Opc.Ua.Bindings.Https`)

- `WebApiTransportOptions`: `ServiceSet`, `OpenApiDocumentPath` (default
`null`: **off**) and `OpenApiIncludeSchemas` (default `false`).
- `MapWebApiEndpoints(WebApiTransportOptions)` maps the routes of the
service set and, when a path is set, the `GET` route of the document
**in the same route group**. The route has no `AllowAnonymous` metadata.
When an authentication scheme is registered, the contributor's
`group.RequireAuthorization()` covers the document like `POST /read`;
only `/findservers` and `/getendpoints` stay anonymous. The existing
`MapWebApiEndpoints()` maps all routes as before.
- The document is generated once per path base (listed as a relative
`servers` URL; without a path base the list is omitted) by the
`WebApiOpenApiGenerator` that `AddWebApiTransport` registers, or by a
default one.
- `Opc.Ua.Bindings.Https` references `Opc.Ua.Core.Schema` on net8.0 and
later.

### Documentation

- `docs/OpenApi.md` (new, linked from `docs/README.md`): serving the
document, the two levels, generating clients, the JSON generated clients
send, known issues, how the document is kept correct, troubleshooting.
- `docs/WebApi.md`: new section "Service sets and the OpenAPI document";
the deferred follow-up is removed. `docs/SchemaGeneration.md` and both
`NugetREADME.md` files describe the generator and the options.

## Comparison with the model compiler documents

The two published documents are test resources of
`Opc.Ua.Core.Schema.Tests` only (README next to them: source commit and
update procedure). `WebApiOpenApiConformanceTests` compares the
generated document with them for both service sets:

- paths, `operationId`s, and the request and response schema of every
operation;
- the set of component schemas the operations reach: equal (100 for all
services);
- every enumeration (members and values);
- for every structure: property names, types, formats, integer ranges,
and inheritance.

Everything both describe is equal. The test lists the differences and
fails when a listed one disappears or a new one appears:

| Difference | Generated | Published | Reason |
| --- | --- | --- | --- |
| DataValue status property | `Status` | `StatusCode` | Part 6 Table 42;
UA-Nodeset#146 is open. The stack only knows `Status` (encoder and
decoder). |
| `description`, `default` keywords | not written | spec links, .NET
defaults | documentation and defaults, not contract; Compact omits
default values |

`WebApiOpenApiWireFormatTests` validates what the binding really writes
(the default request and response of all 28 routes, plus populated
requests and responses with variants, status codes, references, identity
tokens and notifications) against the generated component schemas.

## Tests

- `Opc.Ua.Core.Schema.Tests` `OpenApi/`: `WebApiOpenApiGeneratorTests`
(shape of the document, both levels, both service sets, servers list,
sorting and determinism, concurrency, argument and resolver errors,
every built-in type, arrays and ranks, enumerations, option sets,
unions, base types, self-references, unknown definitions),
`WebApiOpenApiConformanceTests`, `WebApiOpenApiWireFormatTests`,
`OpenApiBuiltInSchemasTests`: 81 tests.
- `Opc.Ua.Bindings.Https.WebApi.Tests`:
`WebApiOpenApiDocumentEndpointTests` (TestServer with the startup
contributor: off by default, configured path, both levels, service set
maps and describes only the sessionless routes, path base, repeated
requests, registered generator is used, **a request without credentials
gets 401 when an authentication scheme is registered while one with
credentials gets 200, the route carries `IAuthorizeData` and no
`IAllowAnonymous`**, argument checks), options defaults and DI
registration of the generator.
- `Opc.Ua.Core.Tests`: `GetRoutes` for both sets and an undefined value.
- `Opc.Ua.Aot.Tests`: the generator and a served document under the AOT
fixture.
- Results (macOS arm64): `Opc.Ua.Core.Schema.Tests` 260/260,
`Opc.Ua.Bindings.Https.WebApi.Tests` 457/457, `Opc.Ua.Core.Tests` WebApi
tests 110/110, each on net10.0 and net8.0 (Release). `Opc.Ua.Aot.Tests`
(JIT run) SchemaAotTests 5/5, WebApiAotTests 8/8.
- `Opc.Ua.Core`, `Opc.Ua.Core.Schema` and `Opc.Ua.Bindings.Https` and
the three test projects build for net48, net8.0, net9.0 and net10.0
(Release) with 0 warnings and 0 errors (net48 builds only; the Windows
legs of the fork CI run its tests). `dotnet format` whitespace, style
and analyzers (`--severity info`) are clean on every changed file; the
two `RCS1090` hits in `SchemaAotTests.cs` are on lines that master
already has.
- Patch coverage with `.azurepipelines/check-coverage.ps1` against
master (`Opc.Ua.Core.Schema.Tests`, `Opc.Ua.Bindings.Https.WebApi.Tests`
and the WebApi tests of `Opc.Ua.Core.Tests`, net10.0): 362/362 changed
lines covered.
- Fork CI (full matrix incl. macOS) and CodeQL on `f990af5db` (master
with #4674 merged in): [CI run
37310622902](https://github.com/biancode/UA-.NETStandard/actions/runs/37310622902)
green, [CodeQL run
37310626786](https://github.com/biancode/UA-.NETStandard/actions/runs/37310626786)
green. The run on the earlier head `abda0e491` had one failure outside
this PR:
`McpServerProcessTests.HttpTransportAliasesExposeOnlyMcpRouteAsync("http")`
timed out after 30 s on Windows net10.0; this PR does not touch
`tools/`.

## Not in this PR

- Authentication: the fix discussed in the review is @romanett's #4674.
The document route uses whatever the REST routes use; this PR does not
change authentication.
- `StatusCode` tolerance in the decoder: dropped, the spec text is the
reference. The first commit of the previous version is gone.
- Samples.
- Session-less invocation on the server side (OPC 10000-4 §6.3) is a
separate draft PR. With `ServiceSet = Sessionless` this PR only limits
the mapped routes and the document.

## Questions

1. `Opc.Ua.Bindings.Https` now references `Opc.Ua.Core.Schema` (net8.0
and later) because the generator lives next to the other schema
generators and is trimmed away when unused. Fine, or should it move into
`Opc.Ua.Core`?
2. Should `OpenApiIncludeSchemas` default to `false` as here, or should
a host get typed clients out of the box?

## Related Issues

- Fixes #4656

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-10-06 09:43:28 +02:00

19 KiB

OPC UA REST Binding (Part 6 §G.3 "OpenAPI Mapping")

The OPC UA REST binding exposes the OPC UA service set as ASP.NET Core Minimal-API endpoints, implementing the OpenAPI Mapping defined in OPC UA Part 6 §G.3 (v1.05.07).

The binding ships as part of OPCFoundation.NetStandard.Opc.Ua.Bindings.Https (net8+ only; mounted on the same Kestrel host as the binary and application/opcua+uajson sub-profiles).

Contents

Quick start

using Microsoft.Extensions.DependencyInjection;
using Opc.Ua;
using Opc.Ua.Bindings;

services
    .AddOpcUa()
    .AddHttpsTransport()        // existing binary + opcua+uajson
    .AddWebApiTransport()      // adds REST routes alongside them
    .AddWebApiAnonymousAuth(); // or AddWebApiBearerAuth(...) etc.

The REST surface mounts inside the existing HttpsTransportListener Kestrel pipeline, so a single port serves binary, application/opcua+uajson, and REST traffic without configuring a second listener.

A spec-shaped client request:

curl -X POST https://server:4843/read \
     -H 'Content-Type: application/json' \
     --data '{
       "RequestHeader": { "TimeoutHint": 10000 },
       "TimestampsToReturn": 2,
       "NodesToRead": [
         { "NodeId": "i=2258", "AttributeId": 13 }
       ]
     }'

returns the matching ReadResponse body. Add ?encoding=verbose to the Content-Type (or Accept) to opt into the Verbose JSON flavour.

Routes (full coverage)

The binding implements every service in the spec's opc.ua.openapi.allservices.json document — 28 services across the Discovery, Session, View, Attribute, Method, MonitoredItem, and Subscription service sets. NodeManagement (Part 4 §5.8) and Query (§5.10) are not in scope — the spec document deliberately omits them.

Service set (Part 4) Routes
Discovery (§5.5) /findservers, /getendpoints
Session (§5.7) /createsession, /activatesession, /closesession, /cancel
View (§5.9) /browse, /browsenext, /translate, /registernodes, /unregisternodes
Attribute (§5.11) /read, /write, /historyread, /historyupdate
Method (§5.12) /call
MonitoredItem (§5.13) /createmonitoreditems, /modifymonitoreditems, /setmonitoringmode, /settriggering, /deletemonitoreditems
Subscription (§5.14) /createsubscription, /modifysubscription, /setpublishingmode, /publish, /republish, /transfersubscriptions, /deletesubscriptions

All routes are POST with a JSON body holding the matching <Service>Request; the response body is the matching <Service>Response. The route table is the source of truth — see WebApiServiceRoutes in src/Opc.Ua.Core/Stack/WebApi/.

Service sets and the OpenAPI document

WebApiTransportOptions.ServiceSet selects the services the binding maps:

WebApiServiceSet Routes
AllServices (default) all 28
Sessionless /read, /write, /historyread, /historyupdate, /call, /browse, /browsenext, /translate (the services a client can call without a session, OPC 10000-4 §6.3)

Sessionless leaves discovery and session management to the binary and opcua+uajson endpoints of the same listener.

The binding can describe the routes it maps as an OpenAPI 3.0 document. The route is off by default; set OpenApiDocumentPath to serve the document of the selected service set with GET. The route belongs to the REST routes and requires the same authentication and authorization as they do (only /findservers and /getendpoints are anonymous).

services.AddOpcUa()
    .AddHttpsTransport()
    .AddWebApiTransport(opt =>
    {
        opt.ServiceSet = WebApiServiceSet.Sessionless;
        opt.OpenApiDocumentPath = "/openapi.json";  // default null: not served
        opt.OpenApiIncludeSchemas = true;           // default false
    });

The document is generated from the code, not copied from the publication: WebApiOpenApiGenerator (package Opc.Ua.Core.Schema) builds it from WebApiServiceRoutes and the structure definitions of the request and response types, so it describes what the binding actually accepts and sends. Without OpenApiIncludeSchemas each body is a JSON object and the document is about 12 KB; with it the document has one component schema per request, response and contained structure and enumeration (about 46 KB for all services) and is what an OpenAPI client generator needs to emit typed models. A conformance test compares the generated document with the ones the OPC Foundation publishes in UA-Nodeset/OpenApi; the one difference in the contract is the status field of a DataValue (Status per Part 6 Table 42, StatusCode in the publication, UA-Nodeset#146).

Hosts that compose their own pipeline call endpoints.MapWebApiEndpoints(options); the document route joins the returned group, so group.RequireAuthorization() covers it as well. The OPC UA over OpenAPI guide covers generating clients, the JSON generated clients send, their known issues and how the document is kept correct.

Encoding negotiation

OPC UA Part 6 §5.4 defines two JSON flavours: Compact (default, mandatory) and Verbose. The REST binding selects between them via the encoding media-type parameter:

Request Content-Type Server response Content-Type
application/json application/json; encoding=compact
application/json; encoding=compact application/json; encoding=compact
application/json; encoding=verbose application/json; encoding=verbose
application/json; encoding=verbose + Accept: application/json; encoding=compact application/json; encoding=compact

The Accept header takes precedence for the response encoding; the Content-Type header governs how the inbound body is decoded. Unknown encoding parameter values fall back to Compact.

Discovery

When the binding is registered, the HttpsServiceHost emits a discovery-only twin per SecurityMode.None HTTPS-binary endpoint with:

  • TransportProfileUri = http://opcfoundation.org/UA-Profile/Transport/https-uajson-openapi (OPC Foundation profile/2338; surfaced via Profiles.HttpsOpenApiTransport)
  • SecurityMode = None (TLS provides transport security)
  • SecurityPolicyUri = None
  • ServerCertificate and UserIdentityTokens copied from the companion HTTPS-binary description so clients can pick a compatible identity at activate time.

Clients discover the binding through GetEndpoints and resolve it by the TransportProfileUri — the synthetic registry-key scheme opc.https+webapi (Utils.UriSchemeOpcHttpsWebApi) maps the profile to the WebApiTransportChannelFactory while the wire-level URL stays https://.... The fluent shortcut ManagedSessionBuilder.UseWebApiEndpoint(url) constructs the endpoint description with all of these fields pre-populated.

The companion WebSocket sub-profile Profiles.WssOpenApiTransport (OPC Foundation profile/2339) is implemented as a peer to the HTTPS surface: WebApiWssTransportChannel on the client side, HttpsTransportListener.AcceptWebSocketOpenApiAsync on the server side. Fluent shortcut: ManagedSessionBuilder.UseWssOpenApiEndpoint(url, encoding). The bearer token is encoded in the selected sub-protocol name (opcua+openapi+<accesstoken>) because browser WebSocket APIs forbid custom HTTP request headers.

Security considerations for the bearer-prefix sub-protocol: the WebSocket spec requires the server to echo the selected sub-protocol back to the client in the 101 handshake. The bearer token therefore appears in plain text in:

  1. The 101 response on the wire (rejected by the client when the URL scheme is not wss://; rejected by the server when the request is not IsHttps).
  2. The server's Kestrel access log (Sec-WebSocket-Protocol header).
  3. Any HTTP proxy / WAF / load-balancer log on the path.

Operators must redact the Sec-WebSocket-Protocol header from logs and should use short-lived tokens (≤ 60 s TTL) so a captured token expires before it can be replayed. The server also requires AddWebApiBearerAuth(...) to be registered: without a bearer validator the listener fail-closed rejects every bearer-prefix upgrade rather than echo the token back.

The persistent WSS client multiplexes requests with connection-local wire handles while preserving caller-visible handles. See Transports for cancellation, late-response routing and connection-lifetime behavior.

Wire format

The body is the bare <Service>Request / <Service>Response object — no {UaTypeId, UaBody} envelope at the HTTPS layer. Body serialization uses the stack's existing JsonEncoder / JsonDecoder driven by WebApiBodyCodec. The encoder shape matches the spec's component schemas property-for-property; see WebApiEncoderConformanceTests in tests/Opc.Ua.Core.Tests/Stack/WebApi/.

Authentication

Four auth modes are pluggable via DI:

services.AddOpcUa()
    .AddHttpsTransport()
    .AddWebApiTransport()
    .AddWebApiAnonymousAuth()                                      // baseline
    .AddWebApiBearerAuth(opt => { opt.Authority = "..."; })        // JWT
    .AddWebApiBasicAuth(async (user, password) => { /* ... */ })   // RFC 7617
    .AddWebApiMutualTlsAuth(opt => { opt.AllowedCertificateTypes = ... });
  • Anonymous — no Authorization header; identity = AnonymousIdentityToken.
  • Bearer JWT — standard Microsoft.AspNetCore.Authentication.JwtBearer middleware; the JWT flows into the OPC UA dispatcher through ISessionlessIdentityProvider.
  • HTTP Basic — in-package BasicAuthenticationHandler. Rejects requests over plain HTTP by default (Options.RequireHttps = true).
  • Mutual TLS — Microsoft.AspNetCore.Authentication.Certificate against the client cert presented to Kestrel. Kestrel itself must be configured to request client certificates (the existing HttpsSettings.HttpsMutualTls flag is the supported path).

For sessionless services (Read/Write/Browse/…) the resolved IUserIdentity is attached to the WebApiInvocationContext.Identity and flows into the dispatcher's role-based-access pipeline through ISessionlessIdentityProvider. For session-based services the client is responsible for calling CreateSession + ActivateSession with credentials in the request body — the binding does not double-authenticate.

The WebApiHttpsStartupContributor automatically inserts app.UseAuthentication() between UseRouting() and UseEndpoints() whenever at least one auth scheme is registered (detected via IOptions<AuthenticationOptions>.Schemes), so the HttpContext.User is populated before the dispatcher invokes the sessionless identity provider. Bindings registered without any AddWebApi*Auth() call skip the authentication middleware entirely to preserve the historical anonymous request flow.

The default ISessionlessIdentityProvider is intentionally conservative; register a custom implementation to map richer claim sets (e.g. JWT role claims → OPC UA roles).

JWT claim projection (built-in)

For services configured with Microsoft.AspNetCore.Authentication.JwtBearer, the binding ships a built-in identity provider that projects the authenticated JWT principal onto an OPC UA IUserIdentity:

services.AddOpcUa()
    .AddHttpsTransport()
    .AddWebApiTransport()
    .AddWebApiBearerAuth(opt => { opt.Authority = "..."; })
    .UseJwtClaimIdentityProvider(opt =>
    {
        opt.SubjectClaim = "sub";    // default
        opt.ScopeClaim = "scope";    // default — space- or comma-separated
        opt.RolesClaim = "roles";    // default — also picks up ClaimTypes.Role
        opt.ReturnAnonymousForUnauthenticated = false; // default
        opt.TransformIdentity = (identity, ctx) => identity; // optional hook
    });

The provider builds a UserIdentity carrying the raw bearer token as an IssuedIdentityToken (Profiles.JwtUserToken) so server-side authenticators can re-validate the JWT downstream, and exposes a projection of the subject / scopes / roles via the static helpers JwtClaimSessionlessIdentityProvider.GetSubject(IUserIdentity), GetScopes(IUserIdentity), and GetRoles(IUserIdentity) for role-based-access lookups. Scopes accept either the single space-separated OAuth 2.0 form or repeated scope claims; roles accept comma/semicolon/space-separated values plus ClaimTypes.Role mappings.

Hosting modes

services.AddWebApiTransport(opt =>
{
    opt.HostingMode = WebApiHostingMode.SharedWithHttpsListener; // default
    opt.DefaultEncoding = WebApiEncoding.Compact;                // spec default
});
  • SharedWithHttpsListener (default) — Minimal-API endpoints mount into the existing HttpsTransportListener Kestrel pipeline via the internal IHttpsListenerStartupContributor hook. Single port for binary / opcua+uajson / REST.

Long-poll /publish

PublishRequest/PublishResponse flow through the same dispatcher the binary path uses. The server side awaits the next notification (or the request's RequestHeader.TimeoutHint) without blocking a thread. Kestrel timeouts must exceed the largest expected TimeoutHint:

webHostBuilder.UseKestrel(o =>
{
    o.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(5);
    o.Limits.RequestBodyTimeout = TimeSpan.FromMinutes(5);
});

On the client, set WebApiClientOptions.RequestTimeout = Timeout.InfiniteTimeSpan for the publish endpoint so the server-side TimeoutHint governs cancellation.

Client integration

The OPC Foundation publishes reference WebApi clients for several ecosystems:

On the .NET stack this binding's REST surface is wired into ManagedSession via the fluent ManagedSessionBuilder.UseWebApiEndpoint(url, encoding) shortcut, so applications open and operate a WebApi session through the same ISession / subscription / monitored-item API that the binary transport uses:

using Opc.Ua.Client;

await using ManagedSession session = await new ManagedSessionBuilder(telemetry)
    .UseWebApiEndpoint("https://server:4843/")
    .WithSessionName("Aot-Sample-Client")
    .WithUserIdentity(new UserIdentity())
    .StartAsync(ct)
    .ConfigureAwait(false);

ReadResponse response = await session.ReadAsync(new ReadRequest
{
    RequestHeader = new RequestHeader { TimeoutHint = 10000 },
    NodesToRead = new ArrayOf<ReadValueId>(/* … */)
}, ct).ConfigureAwait(false);

The companion UseWssOpenApiEndpoint(url) shortcut binds the same session model to the WebSocket opcua+openapi sub-protocol.

Client errors

Errors of a processed request come back in the OPC UA response (ResponseHeader.ServiceResult, HTTP 200). An HTTP error status alone does not tell whether the service ran. The WebApi server of this stack sends 400 (undecodable body), 401 and 403 (authentication and authorization), 404 and 405 (no matching route), 413 (request body over the limit) and 429 (rate limiting) before it invokes the service. For any other status the outcome is unknown: the server answers 500 when it cannot encode the response of a service that already ran, and a proxy or gateway can answer 502, 503 or 504 after the server executed the request. A status from a proxy or another server implementation tells only what that component reports. Retry a service that changes state, such as Write or Call, only when repeating it is harmless. WebApiClient (and therefore ManagedSession over UseWebApiEndpoint) reports the HTTP error as a ServiceResultException:

HTTP StatusCode
400 Bad_DecodingError (the server could not decode the body)
401 Bad_IdentityTokenInvalid when the client sent no credentials, Bad_UserAccessDenied when it did
403 Bad_UserAccessDenied
404, 405, 501 Bad_ServiceUnsupported (the route is not mapped)
408, 504 Bad_Timeout
413 Bad_RequestTooLarge
415 Bad_DataEncodingUnsupported
429, 503 Bad_ServerTooBusy, with a Retry-After header as RetryAfterMs=<n> in AdditionalInfo
500 Bad_InternalError
any other Bad_CommunicationError

The message names the HTTP status, the reason phrase and the route, and the inner exception is an HttpRequestException (on .NET 5 and later with its StatusCode set). An elapsed RequestTimeout or HttpClient.Timeout is Bad_RequestTimeout. A request that fails below HTTP (no connection, failed TLS handshake) gets the StatusCode the HTTPS transport channel reports for the same failure, for example Bad_NotConnected for a refused connection. Cancelling the caller's token still throws OperationCanceledException.

Compressed responses

OPC 10000-6 §7.4.5 lets JSON messages be compressed with gzip (IETF RFC 1952), announced by Content-Encoding: gzip. On the client, WebApiClientOptions.AcceptCompressedResponses = true sends Accept-Encoding: gzip (on a shared HttpClient per request, without changing its default headers):

using WebApiClient client = WebApiClient.Create(
    new Uri("https://server:4843/"),
    new WebApiClientOptions { AcceptCompressedResponses = true });

A gzip response is inflated whether it was asked for or not. The MaxMessageSize limit applies to the inflated body, a corrupt gzip body is Bad_DecodingError, and a content coding other than gzip or identity is rejected with Bad_DecodingError. The HTTPS binary channel reads its responses through the same code.