Skip to content

WebSocket Relay

The relay provides an always-on channel from the edge daemon to the Galois cloud backend. When the backend cannot reach the edge via the Tailscale overlay network (e.g., the edge is behind NAT or Tailscale is unavailable), it uses this channel to push instrument commands.

VariableDefaultDescription
RELAY_URLderivedExplicit ws:// or wss:// URL. If empty, derived from BACKEND_URL.
BACKEND_URLIf set, relay URL is wss://<host>/api/v1/relay/ws.
REGISTRATION_TOKENBearer token used to authenticate the relay connection.

Set RELAY_URL="" explicitly to disable the relay entirely. If both RELAY_URL and BACKEND_URL are empty the relay goroutine is never started.

The registration token is sent in the HTTP Authorization: Bearer <token> header on the initial WebSocket Upgrade request. The token is not appended to the URL query string, which avoids logging it in reverse-proxy access logs.

All frames are JSON text messages (text WebSocket opcode). Every message has a type field that selects the frame variant; all other fields use omitempty, so unused keys are absent from the wire.

  1. Daemon connects; backend validates token from the Authorization header.
  2. Daemon sends hello.
  3. Backend optionally sends hello_ack (see hello_ack).
  4. If the backend rejects the session it sends a close frame with a structured application code (see Unrecoverable close codes).

Sent once immediately after the WebSocket handshake completes, before any other traffic.

{
"type": "hello",
"edge_id": "d3f1a2b4-...",
"edge_name": "pi5-lab",
"version": "0.9.1"
}
FieldTypeDescription
edge_idstringRegistration UUID of this edge.
edge_namestringHuman-readable edge name from config.
versionstringDaemon semver string.

The auth token is carried in the HTTP Authorization header of the Upgrade request, not in this frame.

Sent every 30 s while the session is alive.

{
"type": "heartbeat",
"timestamp_ms": 1746556800000
}
FieldTypeDescription
timestamp_msint64Unix millisecond timestamp at time of send.

The only inbound frame type the daemon acts on. All other unknown types are logged at DEBUG and discarded without closing the session.

{
"type": "command_request",
"request_id": "uuid-456",
"instrument_id": "GPIB0::22::INSTR",
"command_name": "measure_voltage",
"parameters": {"range": "10"},
"is_query": true
}
FieldTypeDescription
request_idstringUUID; echoed back in the corresponding command_response.
instrument_idstringVISA resource string (e.g. GPIB0::22::INSTR).
command_namestringName of the instrument capability to invoke.
parametersobjectKey/value string map of command parameters.
is_querybooltrue if a response value is expected.

Each command_request is handled in its own goroutine. Slow instruments do not block the heartbeat or other commands.

Sent after each command_request is processed. One response per request, always sent (success or failure).

{
"type": "command_response",
"request_id": "uuid-456",
"success": true,
"data": "1.234",
"scpi_command": "MEAS:VOLT:DC?",
"execution_time_ms": 45
}

On failure, success is false, data is absent, and error_message is populated.

FieldTypeDescription
request_idstringEchoed from the request.
successbooltrue if the command executed without error.
datastringResult value (omitted if success is false).
error_messagestringHuman-readable error (omitted if success is true).
scpi_commandstringRaw SCPI string that was sent (if applicable).
execution_time_msint64Wall-clock time from request receipt to response send.

An optional server-sent acknowledgement after the backend validates the hello frame. Carries a session_id for log correlation.

{
"type": "hello_ack",
"session_id": "sess-abc123"
}

This feature requires both a daemon build with the relay_hello_ack build tag and a matching backend that sends hello_ack. See hello_ack feature flag below.

The relay reconnects automatically with exponential backoff:

  • Formula: min(2s × 2^(attempt−1), 5 min) × (1 + rand(0, 0.25))
  • Initial delay: 2 s
  • Cap: 5 minutes
  • Jitter: up to 25% extra (avoids thundering herd)

The backoff counter is reset to 0 after each clean session ends, so a daemon that runs successfully for hours reconnects promptly after a transient drop rather than waiting at the cap.

When the backend closes the WebSocket with one of the following codes the daemon logs an error and does not retry. Operator action is required.

CodeMeaningDaemon action
4401Bad or expired registration tokenLog ERROR, exit relay goroutine
4403Edge not registered on this backendLog ERROR, exit relay goroutine
4426Protocol version mismatchLog ERROR, exit without operator action
1008RFC 6455 policy violation (catch-all auth failure)Log ERROR, exit relay goroutine

For all other close codes the daemon reconnects with backoff.

The hello_ack handshake step is gated behind a Go build tag because it requires a coordinated backend change. The default build does not wait for hello_ack and remains backward-compatible with older backends.

To enable the feature:

Terminal window
# Build the daemon with hello_ack support
go build -tags relay_hello_ack ./cmd/galois-edge
# Run tests with the feature enabled
go test -tags relay_hello_ack ./internal/relay/...

When enabled, the daemon waits up to 10 s for hello_ack after sending hello. If the first frame is not hello_ack, the session is closed and retried with backoff. This gives the backend a hook to return a session ID for log correlation and a place to signal session-level errors after a successful WebSocket upgrade.

Backend coordination required. Before flipping this tag in production:

  1. Deploy a backend version that sends {"type":"hello_ack","session_id":"..."} after validating hello.
  2. Roll out the daemon build with -tags relay_hello_ack.
  3. Verify session_id appears in daemon logs and cloud logs for correlation.
  • The registration token is never logged by the daemon.
  • The token is not appended to the URL query string, avoiding exposure in reverse-proxy logs.
  • In-flight command_request goroutines are cancelled when the relay session ends (e.g., on auth failure), preventing continued gRPC dials against a dead socket.