Skip to content

Cross-Transport Parity Invariant ​

Connectum exposes two transports for ConnectRPC services:

  • the HTTP/2 transport — the production wire protocol;
  • the in-process transport — createLocalTransport(server) / Server.client(ServiceDesc), which delivers requests into the registered route handlers in memory, without a socket, over Connect's binary protocol (messages are still encoded and decoded).

The framework guarantees a Behavioural Parity invariant between them:

For every observable behaviour of a ConnectRPC service, the in-process transport produces results that structurally match the HTTP/2 transport. Observable behaviours include response payloads, response headers and trailers, ConnectError codes / messages / metadata / details, the order of streaming messages, cancellation propagation, OpenTelemetry spans (modulo the connectum.transport attribute), and metric label sets (modulo the transport label).

This invariant is release-blocking: any PR that breaks it must either be revised or accompanied by a documented carve-out in the spec.

How the invariant is enforced ​

  1. transportParityTest() driver (@connectum/testing/parity) runs every scenario twice — once over createGrpcTransport({ baseUrl }) and once over createLocalTransport(server) — and performs a structural diff on the normalized result (response, headers, error, OTEL spans, metrics).

    What the default comparison (defaultCompare) treats as equal, beyond identical values:

    • Protocol headers. Response headers, trailers and ConnectError metadata are compared as application data. The HTTP/2 leg and the in-process leg carry different protocol headers for identical behaviour (content-type, content-length, date, grpc-encoding, grpc-status, grpc-message), so content-type, content-length, content-encoding, accept-encoding, date, te and the grpc-* and connect-* name families are dropped on both sides first. Every other entry is compared literally, so a scenario can report the headers exactly as the client sees them. A scenario that must compare a protocol header passes its own compare.
    • Duration histograms. A histogram measured in a unit of time (rpc.server.call.duration, rpc.client.call.duration) holds the elapsed time of that run, which differs between runs. It is compared by its observation count and attributes, not by sum, minimum, maximum or buckets. Names, descriptions, units, types and attributes of every metric, and the values of every other metric (sizes, counters, gauges), are compared literally.
    • Spans are compared without trace and span identifiers (only whether a span has a parent), and readMaxBytes rejections are equal when they name the same limit, whether or not the text includes the observed size.

    Telemetry from @connectum/otel reaches ctx.spans and ctx.metrics only when the scenario sets it up: run with OTEL_TRACES_EXPORTER=none and OTEL_METRICS_EXPORTER=none, and call shutdownProvider() from @connectum/otel at the start of every scenario, before registering the collectors with trace.setGlobalTracerProvider() and metrics.setGlobalMeterProvider(). @connectum/otel keeps the tracer of the first provider registered, so without the shutdown every later scenario records nothing, and two empty lists compare as equal; assert that the telemetry you compare is not empty. A second ctx.metrics.flush() returns only what was recorded since the previous one, and flush() after the run was disposed throws.

    The opt-in telemetry: { shutdownProvider } option makes the driver do that sequence around every run: it resets the @connectum/otel provider and registers that run's span and metric collectors, so the second and later scenarios in a process record spans too (see Spans and metrics of @connectum/otel in a test).

  2. Parity suite — aggregated by scripts/parity-suite.sh, which runs the parity tests of @connectum/testing (interceptors, validation, streaming, error mapping, coexistence, request admission, reflection, catalog-routed ctx.call / ctx.stream), @connectum/auth (authentication and authorization), and @connectum/otel (OTEL). Validation and authorization scenarios run the shipped interceptors (createDefaultInterceptors(), createJwtAuthInterceptor, createProtoAuthzInterceptor) against fixtures with real buf.validate and connectum.auth.v1 annotations; each scenario checks the outcome against an expected value as well as comparing the two transports. A test-local interceptor that imitates them does not count as coverage.

  3. HTTP ↔ in-process parity CI check (.github/workflows/parity-gate.yml) runs the suite on every pull request and every push to main.

When you MUST add a parity scenario ​

Any change that touches observable RPC behaviour must add (or extend) a transportParityTest() scenario in the appropriate file under packages/testing/tests/parity/, packages/auth/tests/parity/ (authentication and authorization), or packages/otel/tests/parity/ (observability). This includes, but is not limited to:

  • a new interceptor in @connectum/interceptors;
  • a new authentication or authorization rule in @connectum/auth (programmatic or proto-declared);
  • a new validation rule wired through protovalidate / buf.validate;
  • a new OpenTelemetry instrument, attribute, or span event in @connectum/otel;
  • any change to ConnectError mapping in @connectum/core or interceptors;
  • any change to header / trailer propagation;
  • any change to streaming semantics (ordering, cancellation, back-pressure surfaces).

If the change is purely transport-local — for example, an HTTP-only header that has no in-process analogue — document the carve-out in the parity test file as a code comment and in the spec.

When parity does not apply ​

A small set of behaviours are explicitly transport-specific and are not asserted by the parity driver:

  • Network-level concerns: TLS, HTTP/2 framing, TCP keepalive, :authority pseudo-header, real req.url host/port.
  • Wire-format concerns: gzip/identity content-encoding negotiation, message framing on the wire, and the protocol headers that carry them (the driver drops them before comparing headers, trailers and error metadata).
  • The measured time of a duration histogram (its observation count is compared).
  • The synthetic origin https://in-memory/<service>/<method> that the in-process transport injects for interceptors that read req.url.
  • The connectum.transport span attribute and the transport metric label, which differ by design and are stripped before structural diff.
  • The diagnostic text of a readMaxBytes rejection. Connect includes the observed message size only when it knows the total length up front: the gRPC envelope over HTTP does, the in-process unary body does not. So one transport may report message size 70 is larger than configured readMaxBytes 64 and the other message size is larger than configured readMaxBytes 64. defaultCompare treats exactly this pair as equal when both errors are resource_exhausted and name the same limit; the code, the limit, metadata, details, and whether the handler ran are still compared as-is, and any other message difference still fails.

Server shutdown is not an exception either: server.stop() aborts the handler signal of in-flight calls on both transports (since 1.3.0).

Request admission itself is not an exception: a server's requestGate and readMaxBytes apply identically on both transports, with no in-process exemption (see Request admission).

Pull request checklist ​

Every PR that touches a service-observable surface must:

The parity check fails on a structural diff. Run it locally with bash ./scripts/parity-suite.sh from the framework root before submitting a change.

References ​