Service transports and encodings¶
What sysml-grpc serves on its port, which body encoding a client should choose, and the
flags that decide it. Written for someone about to write a client. The measurements and the
reasoning behind the choice are
the transport evaluation.
One port, four ways in¶
sysml-grpc serves, by default, all of these on -port (50051):
| Protocol | Content type | Who speaks it |
|---|---|---|
| gRPC | application/grpc |
grpc-go, grpc-java, tonic, grpcio (the Python client), grpcurl |
| gRPC-Web | application/grpc-web, application/grpc-web+json |
browser clients using fetch |
| Connect protocol | application/proto, application/json |
connect-go, @connectrpc/connect-web, and any HTTP client at all |
| gRPC server reflection | — | grpcurl, grpcui |
Plus GET /health, described below. A Connect unary call is an ordinary POST to
/sysml.SysMLService/<Method> whose whole body is the request message, so curl is a
first-class client and no generated code is needed to reach the service.
The service is one implementation behind all of them: the same fifteen RPCs of
api/proto/sysml.proto,
the same semantics, the same status codes. An existing gRPC client — including a generated
grpc-go stub, grpcurl and the opensysml Python client — reaches the default server
unchanged.
-transport grpc serves the grpc-go server alone, as releases before this one did: no
Connect, no gRPC-Web, no curl, health on -health-port only. It is an escape hatch, not the
recommended path. -transport stdio is a prototype, described below.
grpc-go itself is confined to that escape hatch, to tests, to the conformance runner's real
grpc-go client, and to committed generated code: the service logic, the public Go API and the
stdio prototype construct errors with connect-go, whose codes number the same as the canonical
gRPC status codes, so every transport answers the same code and message.
scripts/check-grpc-imports.sh fails CI when production code imports grpc-go again.
The clients this repository ships are on client libraries: the Python client speaks gRPC, the Node, Java and Rust clients speak Connect with protobuf bodies, and the public Go API answers in process without a transport at all.
Capabilities, and what an absent one does¶
GetServerInfo reports a version string and a list of capability names. Negotiate on the
names: a version string tells a client what release answered, not what that release can do for
the call it is about to make.
An absent capability behaves in one of two ways, and which one is part of each capability's definition rather than a client's guess:
| The capability describes | A request that needs it | What a client should do |
|---|---|---|
what the service can be asked: strict_conformance, inline_language, parse_sources, evaluate_subject, verification, convert, apply_edits, authoring, query, oslc_query, document_query, render_document |
is refused with UNIMPLEMENTED, naming the capability |
check the advertised list first, and report the missing capability locally rather than spending a round trip |
how a response is populated: type_facts, symbol_attributes, feature_values, enum_values, unset_value |
is answered with those fields omitted | check before reading the fields; an omitted field is not an error |
So a client cannot treat "the call succeeded" as "the field was computed", and cannot treat
"no refusal" as "the capability is there". Every client this repository ships checks the list
before making a capability-gated call, and maps the service's refusal onto the same error it
raises itself — MissingCapabilityError in Python, CapabilityException in Java,
CodeUnimplemented in Go. The conformance suite runs against the default service and against
a test-only configuration with capabilities withheld, so both columns of that table are
exercised rather than described.
Choose protobuf, not JSON¶
Protobuf is the body encoding for every client shipped or documented by this project. JSON is the debugging affordance. This is not a style preference; it is the strongest measurement in the evaluation:
468 KB Query response |
p50 | p95 | p99 |
|---|---|---|---|
| protobuf body | 6.34 ms | 9.18 ms | 9.88 ms |
| JSON body | 37.88 ms | 41.82 ms | 44.99 ms |
Six times slower, reproducible across runs. The cause is not payload size: the same
answer is 467,971 bytes as protobuf and 513,339 as JSON, 9.7% more. It is protojson encode
plus json_format decode CPU, so a faster link does not help and the cost falls on both
ends. Small answers show no measurable difference between the encodings, which is why the
warning is about large ones.
The server says so at runtime rather than only here: a JSON-encoded response whose message
exceeds 256 KiB logs a warning naming the procedure and the size. The check thresholds
proto.Size, so it costs no second encoding — the number logged is the protobuf size of the
answer, a few percent under what the JSON body will weigh. There is no cheaper JSON path to switch to
— connect-go marshals such a response once, not twice, so there is no double marshal to
remove, and protojson has no streaming encoder to substitute. The mitigation available is
the choice of encoding, and it belongs to the client.
A browser client over Connect-JSON has no protobuf option for its body if it is written
against application/json by hand — but @connectrpc/connect-web speaks
application/proto in the browser and should be generated rather than hand-written for
exactly this reason. Query over the whole model is the call where this decides whether a UI
feels responsive.
Two things a hand-written JSON client must know¶
$ curl -X POST http://localhost:50051/sysml.SysMLService/ParseFile \
-H "Content-Type: application/json" \
-d '{"content":"package Demo { part def Rover { attribute mass = 12.5; } }"}'
{"modelHash":"6245ef48…e78d","root":{"kind":"RootNamespace","childIds":["Demo"]}}
$ curl -X POST http://localhost:50051/sysml.SysMLService/Evaluate \
-H "Content-Type: application/json" \
-d '{"expression":"1 + 2 * 3","modelHash":"6245ef48…e78d"}'
{"result":{"intValue":"7"}}
- An
int64is a JSON string."7", not7— the proto3 JSON mapping, not a quirk of this service.intValue,modelHashlengths, step counts and every other 64-bit field read this way. - Errors are an HTTP status plus a body, not gRPC trailers:
{"code":"not_found","message":"…"}. The code names are the Connect protocol's spelling of the same gRPC codes a gRPC client sees, sonot_foundhere andNOT_FOUNDthere are one status. Every client in every protocol must map them identically; the conformance suite asserts that it does.
A browser client¶
Three prerequisites, in the order they bite:
CORS. -cors-allowed-origins takes a comma-separated list of exact origins
(https://studio.example.org,http://localhost:5173) and is off when empty. A * entry is
refused at startup: a service that answers every origin is not a default worth having.
The allowed set drives the preflight response, and the response exposes gRPC-Web's trailer
headers (Grpc-Status, Grpc-Message, Grpc-Status-Details-Bin) — a gRPC-Web client whose
trailers are not exposed fails in a way that looks like a server bug. CORS is a browser-side
control and not authentication: a non-browser client is unaffected by the list, and this
service still has no authentication of any kind.
TLS. -tls-cert and -tls-key (both or neither) serve everything above over HTTPS on the
same port, negotiating h2 and http/1.1, minimum TLS 1.2. A browser on an https:// page
cannot post to http://, so this is a prerequisite rather than a hardening step. Without the
flags the port is cleartext with h2c, which is what a gRPC client needs against a port that
offers no TLS, and which is appropriate only inside a trusted network or behind a proxy that
terminates TLS. Bidirectional streaming, if it is ever added, would additionally require
HTTP/2 end to end — in a browser that means TLS.
The grpc-web-text gap, stated rather than papered over. connect-go v1.20 implements
application/grpc-web and application/grpc-web+json but not the base64 grpc-web-text
variant; posting that content type answers 415, and a test pins that. grpc-web-text exists
for clients that cannot read a binary response body — the old XMLHttpRequest paths. Any
client using fetch, which is what @connectrpc/connect-web and grpc-web's fetch transport
do, never asks for it. This is acceptable for a fetch-based browser client and only for
one: a client that needs grpc-web-text needs a proxy in front of this service.
Health¶
GET /health answers on the main port and reports the build:
A separate HTTP health port existed because the gRPC-only server could not serve a plain GET.
It no longer has to, so -health-port is deprecated:
| Behavior | |
|---|---|
Today, default (-health-port 8081) |
/health answers on both the main port and 8081; the second listener logs a deprecation warning |
Today, -health-port 0 |
no second listener; /health answers on the main port |
| A future release | the default becomes 0; the flag stays accepted for a release after that |
-transport grpc |
unchanged — 8081 is the only health surface, and no warning is logged |
Poll the main port. Nothing in this repository polls 8081: the Python client's readiness probe
is a GetDiagnostics call over gRPC, not an HTTP GET, so it is unaffected by every row of that
table.
-transport stdio, and why not to build on it¶
-transport stdio serves one client over stdin/stdout with Content-Length framing. It is
not the default, and not a supported client transport: no client this project publishes —
not the Python client, not a generated stub — speaks it, and none will. It exists because a
supervisor that already spawns the binary as a child process can reach the service without a
port, which is the question the evaluation asked;
its answer was to serve clients over a port. Choosing it also gives up everything on this page:
no reflection, no /health, no CORS, no TLS, one client per process. Write a client against the
default port instead. The prototype is kept, behind the flag, and tested — internal/stdiorpc
covers the protocol and cmd/sysml-grpc covers the binary answering a framed call — so that it
cannot rot unnoticed while it is still in the tree.
Every protocol is tested, not merely served¶
A second protocol surface that no test drives rots. The conformance suite
(conformance/) runs its whole
scenario list once per protocol — gRPC, Connect with a protobuf body, Connect with a JSON body
— against one service, asserting identical results and identical status codes:
$ make conformance # all three protocols
$ go run ./cmd/conformance -protocols connect-json # one of them
$ go run ./cmd/conformance -transport grpc -protocols grpc
The JSON-specific edges above — int64 as a string, error shape — are exactly what that
parameterization covers, in the encoding a browser client will use.