gobridge

MQTT options reference

Field-by-field reference for the MQTT transport: session, sender and receiver options, credential URIs, and mutual TLS.

See MQTT for sessions and a worked example, and MQTT behaviour for settlement, resilience and backpressure.


Session Options Reference (options.session.*)

Key Type Default Description
broker_url string Single broker URL (e.g. tcp://host:1883). Folded into broker_urls when the list form is absent.
broker_urls []string Broker URLs for failover. Ephemeral sessions may use multiple independent URLs. Persistent/exclusive sessions reject more than one distinct canonical URL because one managed-filter history cannot safely span independent broker-session domains. Canonical means the endpoint actually dialled: scheme aliases collapse (tcp/mqtt, and ssl/tls/mqtts/mqtt+ssl/tcps), an omitted port becomes the family default (1883 / 8883 / 80 / 443), host case, userinfo and fragment are ignored, and path/query count only for ws/wss. Two durable sessions spelled differently but reaching one endpoint are rejected as duplicate identities at startup instead of disconnecting each other on their shared client_id. A scheme outside that list is rejected.
client_id string MQTT client identifier. Required on the effective (merged) session config at build time, together with at least one broker URL (Config.ValidateEffectiveSession in config_plugin.go, enforced by Factory.NewSession); an empty value is accepted at parse time. For scale-out uniqueness from one shared config file, see client_id_suffix.
client_id_suffix string Opt-in per-instance uniquifier appended to client_id, required for clustered non-Exclusive $share consumers. hostname appends the process-cached hostname; for Persistent sessions it is safe only where hostnames are stable across restarts (StatefulSet/VM — NOT Deployments/ECS, where every rollout orphans the previous broker session and its queued QoS 1/2; the factory rejects this combination unless assert_stable_client_identity: true — see Deployment identity). nonce appends a process-cached random token and is allowed only for Ephemeral sessions. Unset leaves client_id verbatim. Exclusive rejects every suffix because failover resumes one stable shared client ID.
assert_stable_client_identity bool false Affirms that this deployment’s hostnames survive a restart, admitting the one combination the factory otherwise rejects at build time: a Persistent session with client_id_suffix: hostname. On a Kubernetes Deployment or an ECS service every rollout mints a new hostname, so a new client_id, so a new broker session — the previous session’s queued QoS 1/2 messages are stranded until session_expiry_interval expires them, which is loss by timeout and invisible to the bridge. Setting this is the operator vouching for a StatefulSet/VM identity profile; it does not make a Deployment or ECS safe. A warning is logged on every admitted session. Ignored for non-persistent modes and for any suffix other than hostname — see Deployment identity.
keep_alive int 30 Keep-alive interval in seconds. Explicit 0 disables the MQTT pinger — half-open-connection detection then rests on TCP keep-alive alone (much slower, and OS-dependent), so a dead-but-open socket can go unnoticed for minutes. The registry/blueprint path defaults to 30; a direct library consumer that sets 0 should understand this trade-off.
connect_timeout duration 30s Bounds the initial Start connection await
reconnect_timeout duration 30s Bounds each individual (re)connect attempt (TCP dial + TLS + CONNECT/CONNACK). Maps to autopaho ConnectTimeout; 0 → autopaho default (10s).
reconcile_timeout duration 30s (DefaultReconcileTimeout) Bounds each broker SUBSCRIBE / UNSUBSCRIBE issued while reconciling the session plan. The reconcile runs on a possibly deadline-less runtime context, so without this an unresponsive broker (SUBACK/UNSUBACK never arrives on a half-open connection) would hang the reconcile — and any startup / hot-reload step awaiting it — indefinitely. This is a liveness safety bound: a non-positive value is coerced up to 30s and cannot be disabled.
reconnect_delay duration 10s (DefaultReconnectDelay) Base delay of a jittered exponential reconnect backoff. Starts at reconnect_delay, grows 2× (reconnectBackoffFactor) per failed attempt, caps at reconnect_max_delay, then equal-jitters to [d/2, d) to desynchronise a reconnecting fleet (anti thundering-herd). 010s.
reconnect_max_delay duration 2m (DefaultReconnectMaxDelay) Caps the jittered-exponential reconnect envelope. Must be ≥ reconnect_delay; a smaller value is clamped up to the base at Start. 02m.
clean_start bool false MQTT 5 clean-start flag; consulted only for Persistent/Exclusive sessions. clean_start: true on a Persistent session wipes the broker-side session (subscriptions AND queued offline QoS 1/2) on every process restart — the backlog the mode exists to retain is discarded each time. Honoured as configured, with a construction-time warning; on Exclusive it is overridden to false (takeover loop).
session_expiry_interval int 0 MQTT 5 session expiry in seconds. For Persistent/Exclusive sessions a 0 is replaced at session creation (NewSession) with 86400 (24h) — a literal 0 would give zero offline retention. Ephemeral always uses 0.
receive_maximum int 0192 (DefaultReceiveMaximum) MQTT 5 Receive Maximum: max in-flight QoS 1/2 messages the broker may send before PUBACKs. 0 is normalized because it is illegal on the wire. The same effective value sizes one reservation shared by the serialized dispatch queue and startup/migration pending entries; those stores cannot each retain a full independent window. An explicitly configured non-zero value receives full window validation during parse and is rejected when unsafe. An omitted value stays unmaterialized during parse so a deployment profile may derive a lower safe value; generic bridge preflight later applies 192 and performs the same full validation.
max_payload_bytes int 0262144 (DefaultMaxPayloadBytes) Maximum inbound application body, in bytes. CONNECT advertises a separate wire Maximum Packet Size of this body limit plus a 128 KiB MQTT v5 metadata allowance. After TLS/WebSocket decoding but before Paho packet decoding, an adapter-owned connection guard frames one bounded wire packet, validates Remaining Length before allocation, rejects malformed PUBLISH structure and any packet above the advertised total, and truncates a User Property list longer than 129 entries to 129 on the raw bytes so the SDK never decodes more (MQTTIngressUserPropertiesTruncated). The decoded callback then enforces the local caps the broker cannot see — an oversized body, more than 128 User Properties, or topic-plus-properties metadata over 128 KiB — by acking and dropping the packet (MQTTIngressPoisonDropped), never by failing the session. Values too large to retain the metadata allowance below the MQTT 256 MiB − 1 packet ceiling are rejected, never clamped. This does not limit outbound publishes.
ingress_memory_budget_bytes int 0268435456 (DefaultIngressMemoryBudgetBytes) Per-session conservative MQTT ingress budget (256 MiB). The bridge validates the full packet/window equation below using the route’s effective max_in_flight before opening stores or transports. Validation includes ReceiverDef-backed sessions with no consuming route and referenced Persistent/Exclusive sessions that can resume stale backlog. Exact boundary is accepted; one byte over budget and every arithmetic overflow are rejected as invalid config.
unmatched_grace duration 30s Grace window after each connect during which an incoming publish matching no registered receiver filter is buffered (un-acked) awaiting handler registration. It is also the post-recycle no-replay verification window for managed-filter removal; a pinned matching replay or a shorter reconciliation deadline fails migration closed and preserves history. After the window a still-unmatched publish is split by whether a wanted subscription still covers its topic. A topic the session still wants whose handler registered late is retained un-acked and redelivered once the handler registers (MQTTRouterCoveredRetained) — never acked-dropped, so a late-registering live route cannot lose a QoS 1/2 message; only a covered QoS 0 publish the bounded buffer cannot hold is dropped best-effort (MQTTRouterCoveredDropped). An orphan topic no configured route covers (a leftover broker-side subscription on a resumed clean_start=false session) is acked, dropped, and UNSUBSCRIBEd (deduped, one warn per topic) to converge (MQTTRouterUnmatchedDropped, benign cleanup). 0DefaultUnmatchedGrace (30s).
no_local bool false Opt-in MQTT 5 No-Local. When true, every ordinary subscription is issued with the No-Local flag so the broker does not deliver a message back to the same session that published it — breaking the same-broker MQTT→MQTT self-delivery loop where a session that both subscribes and publishes on overlapping filters would otherwise receive and re-forward its own publishes (unbounded self-amplification). Default false preserves the least-surprising MQTT contract (a session receives its own publishes), so existing single-session round-trip topologies are unaffected. A shared subscription ($share/…) never sets No-Local even when this is true: MQTT 5 §3.8.3.1 makes No-Local on a shared subscription a Protocol Error the broker rejects with a DISCONNECT. Cross-bridge delivery is unaffected — No-Local is per-connection and distinct bridges use distinct client_ids. See ADR 0010.
username string Authentication username. Sent in the MQTT CONNECT packet in cleartext — see allow_plaintext_credentials and use a TLS broker scheme (ssl://, mqtts://, …).
password string Authentication password (redacted on marshal). Sent in the MQTT CONNECT packet in cleartext — protect it with a TLS broker scheme.
allow_plaintext_credentials bool false Opt IN to sending username/password over a non-TLS broker URL (tcp://, mqtt://, ws://, or schemeless). Default false fails closed: if credentials are configured and any broker_urls entry is not a TLS scheme, session build is rejected (the credentials would travel in cleartext). tls.enable does not satisfy this — autopaho selects TLS from the URL scheme only. Set true only for trusted transports (private mesh, TLS-terminating sidecar, or a localhost test broker).
will.topic string Last Will and Testament topic (required when will is set; no wildcards)
will.payload string Will message payload
will.qos int 0 Will QoS (0, 1, or 2)
will.retain bool false Will retain flag
tls.enable bool false Builds the client TLS material (CA / client cert / verification mode) below. Does not by itself select a TLS transport — autopaho dials TLS only when the broker URL scheme is a TLS scheme (ssl://, mqtts://, tls://, mqtt+ssl://, tcps://, wss://). On a tcp:// URL the built config is ignored and the connection stays cleartext, so pair tls.enable: true with a TLS scheme.
tls.ca_cert_file string CA certificate file path
tls.cert_file string Client certificate file path
tls.key_file string Client private key file path
tls.ca_cert_pem string CA certificate PEM material (in-memory; wins over ca_cert_file). Typically supplied by credential rotation.
tls.cert_pem string Client certificate PEM material (requires key_pem; wins over cert_file)
tls.key_pem string Client private key PEM material (redacted on marshal; requires cert_pem)
tls.insecure_skip_verify bool false Skip server certificate verification

Duration validation

Every duration above accepts 0, which selects the documented default. A negative value is rejected at configuration validation, so it can never reach a session. Nothing downstream treats a negative duration as an error – it becomes an already-expired context – so a build that accepted one would start successfully and then fail every attempt it made, for a reason that is invisible in the configuration.

Packet acknowledgement budget

The MQTT client applies its own deadline to each packet acknowledgement it waits for (CONNACK, SUBACK, UNSUBACK, PUBACK/PUBCOMP), inside the deadline the bridge already set. Its built-in default is 10 seconds – shorter than every budget on this page – so leaving it alone would silently override them: a SUBACK the bridge was willing to wait 30 seconds for would be abandoned at 10, failing a reconcile while the broker was answering normally.

There is no key for it. The session derives the budget as the longest of connect_timeout, reconnect_timeout, reconcile_timeout, the timeout of every sender bound to the session, and the 30-second sender default – so the adapter-owned bound is always the one that governs. It is not a liveness bound of its own: each packet operation already runs under its own deadline, and that is what bounds an unresponsive broker.

One consequence is worth knowing when tuning shutdown: a SUBSCRIBE or UNSUBSCRIBE still in flight when the session is closed now waits out reconcile_timeout rather than being cut short at the client’s old 10-second default. Cancelling the context passed to Reconcile still ends it immediately, which is what the runtime does on shutdown, so this is visible only to a library consumer that closes a session while holding a longer-lived context open.

Where each ingress cap is enforced

Sizing memory from max_payload_bytes alone understates the peak, because the caps do not all bite at the same point. Three boundaries matter, and only the first two happen before the SDK builds Go objects:

Boundary What it enforces What a violation costs
Broker (CONNECT-advertised Maximum Packet Size) max_payload_bytes + the 128 KiB metadata allowance, as ONE whole-packet limit. It is the only inbound limit a compliant broker enforces. Nothing reaches the bridge.
Predecode connection guard (raw bytes, after TLS/WebSocket, before Paho decodes) Structural validity of the PUBLISH, Remaining Length validated before allocation, total size against the advertised maximum, and the User Property list truncated to 129 entries on the raw bytes (MQTTIngressUserPropertiesTruncated). One raw wire packet buffered. A malformed packet or a total above the advertised maximum fails the session closed — only a broken broker can produce either.
Decoded publish callback (after Paho has built Go objects) The LOCAL representational caps the broker cannot see: an oversized body, more than 128 User Properties, or topic-plus-properties metadata over 128 KiB. The packet is acked and dropped (MQTTIngressPoisonDropped), never failed. One fully decoded packet — the transientDecodedPacketSize term below. The packet is refused only AFTER it has been materialised.

The third row is the memory boundary that matters: a packet that violates a local cap is decoded in full before anything refuses it. That cost is budgeted as crossing, not as a retained slot, because it exists for the duration of one decode and nothing keeps it. The property caps are split for exactly this reason — 128 is what a packet may RETAIN, 129 is the most the SDK will ever DECODE — so the guard bounds the decode while the callback still sees the violation and refuses the packet.

Ingress byte model

Every MQTT session that can own inbound state is validated independently:

packet   = ceil(decodedPacketSize(maxPayloadBytes) * 1.25)
crossing = ceil((wirePacketSize(maxPayloadBytes) + transientDecodedPacketSize(maxPayloadBytes)) * 1.25)
window   = receiveMaximum + dispatchCapacity + routeMaxInFlight
bound    = packet * window + crossing

dispatchCapacity is the effective receiveMaximum, not a fixed queue size. One reservation is shared by dispatch and startup/migration pending entries, so their combined distinct queued packets never exceed that capacity. wirePacketSize is the separately advertised MQTT Maximum Packet Size: payload plus a 128 KiB allowance covering the fixed-header byte, worst-case four-byte Remaining Length encoding, maximal 65,535-byte topic plus its two-byte length, QoS packet identifier, worst-case properties-length encoding, and bounded property bytes. decodedPacketSize adds both Paho User Property struct representations and a 32 KiB fixed allowance for SDK structures, accepted Envelope header-map buckets, outbox/queue state, and allocator page/size-class rounding. The 25% factor covers remaining Go object and slice bookkeeping.

The two decoded terms differ, and the difference is load-bearing. decodedPacketSize — the per-slot retained cost — budgets 128 User Properties, because a packet exceeding that cap is acked-and-dropped by the publish callback before anything retains it. transientDecodedPacketSize — used only by crossing — budgets what ONE SDK decode can hold: four wire-sized allocations (the SDK’s read buffer, the doubled replacement it grows into when the packet ends within one read chunk of the buffer’s capacity, and the topic, property and payload copies it takes out of that buffer) plus 129 User Properties. The CONNECT advertises only a whole-packet Maximum Packet Size, so a compliant broker may forward a packet whose metadata section is nothing but five-byte (empty key, empty value) User Properties — about 78,600 of them in a zero-payload packet at the default limit. The SDK spends roughly 1.3 KiB of allocation decoding each one, around 100 MiB for that single packet, before the callback could refuse it. No budget can honestly absorb that, so the predecode guard removes the excess on the raw bytes instead: the decoder never sees more than 129 User Properties, the callback still refuses the packet, and every such packet is counted on MQTTIngressUserPropertiesTruncated. Because the guard bounds every packet before decoding, the bound holds for the packets the SDK queues ahead of the callback as well as for the one in flight.

The single crossing term is the formula’s +1 ownership slot. It covers one complete raw packet buffered by the predecode connection guard plus Paho’s worst-case decoded representation while that wire packet is consumed. For a rejected packet only the raw half exists, so the same term is conservative. The guard checks the advertised Maximum Packet Size from Remaining Length before allocating and never buffers a second packet. Envelope, no-processor route, and outbox fan-out clones share immutable payload backing; a processor that calls SetPayload owns a new copy. Checked division and overflow guards run before every addition or multiplication.

The typed parser intentionally keeps zero/unset ingress fields unmaterialized through config clone/parse. When receive_maximum is omitted, parse validates only receive-independent packet and minimum-budget prerequisites. The AWS profile then assigns a per-session budget and derives a safe Receive Maximum. An explicit non-zero Receive Maximum receives full validation immediately and is never clamped. Independently of deployment profile, bridge.Builder performs full ValidateIngressMemory(routeMaxInFlight) preflight before opening stores or transports; generic composition therefore applies default 192 and rejects a window that the default 256 MiB budget cannot hold.

The defaults (256 KiB payload, Receive Maximum 192, route max_in_flight 100) produce a 265,185,360-byte bound, below the 256 MiB default budget. Raising payload size, Receive Maximum, or route concurrency may require a larger budget. Do not tune only the message count. A budget smaller than one crossing slot (about 2.4 MiB at the default payload size) is rejected outright: the session could not decode a single legal packet.

The AWS file-based profile reserves 25% of the effective Fargate task memory, divides it across unique included MQTT sessions, and derives the largest safe default Receive Maximum with this same formula. Every started memory-aware session referenced by a ReceiverDef is included, even when no route consumes the receiver, because its effective session plan can subscribe and admit traffic. Every referenced Persistent or Exclusive session is also included: resumed durable broker state may deliver stale backlog before managed-subscription cleanup. Session IDs are deduplicated and route-less sessions contribute zero route concurrency. Ephemeral sender-only sessions with no receiver/subscription remain excluded. The profile rejects an allocation that cannot leave 20% container headroom after reserved_memory_bytes plus MQTT ingress.

Sender Options Reference (options.sender.*)

Key Type Default Description
default_topic string Fallback publish topic used when OutboundMessage.Address is empty. The publish topic is never read from Envelope.Subject. Validated as an MQTT publish topic at build time (wildcards +/#, the $share/ prefix, and null bytes are rejected), because it bypasses the runtime address validator — a malformed value would otherwise only fail at first publish, as a broker DISCONNECT that tears down the shared session for every route on it.
qos int 1 MQTT QoS level (0, 1, or 2)
retain bool false MQTT retain flag
timeout duration 30s Per-publish timeout, applied as the stricter of this value and the caller’s remaining deadline. The session raises the MQTT client’s per-packet acknowledgement budget to cover the longest timeout of any sender bound to it, so the value you configure is the one that governs a PUBACK wait (see Packet acknowledgement budget). On a bridge route the dispatcher already wraps every send in the route’s policy.send_timeout (default 30s), so a timeout shorter than the remaining route deadline tightens the publish while a longer one is capped by the route deadline — it never extends the route ceiling. Note the coercion asymmetry: unlike an explicit qos: 0 or keep_alive: 0 (honoured as-is), a configured timeout: 0 is coerced up to the 30s default at build. The 60s Send-time safety-net for a zero timeout is therefore reachable only by a direct library consumer that constructs SenderOptions and leaves Timeout at 0, bypassing the factory — via config, a 0 becomes 30s. See Resilience Behavior for the interaction with policy.send_timeout.
throttle_retry_after duration 500ms Retry-after hint attached to a publish failure only when the broker returns PUBACK/PUBREC reason 0x97 (Quota exceeded) – the one reason code that signals throttling. Other non-zero reason codes classify as generic errors with no back-off hint.

Credential URI (options.credentials_uri)

credentials_uri is a top-level key under options:, a sibling of session: and sender: (not a session key). It names a credential-store URI (file://…, pms://…) that the runtime credential resolver reads at build time. The resolver merges the resolved username, password, and TLS material (tls_cert/tls_key/tls_ca) into the session options and removes the credentials_uri key before the MQTT transport sees the config, so the secrets never appear in the YAML. See HTTP ingress with credentials for the resolver walkthrough.

Token-style auth is supported: a password (or bearer token) with no username sets the CONNECT password flag independently of the username flag, so a token-in-password credential is no longer dropped for want of a username.

Mutual TLS from a credential store

The two halves of mutual TLS are configured independently, and either can come from a store instead of the filesystem:

Pointing at a credential URI supplies both, so no certificate material appears in the YAML or on disk:

options:
  credentials_uri: "pms:///gobridge/prod/mqtt"   # AWS Parameter Store
  session:
    broker_url: "ssl://broker.example.com:8883"  # TLS comes from the SCHEME
    tls:
      enable: true
      insecure_skip_verify: false

The stored JSON supplies the material the resolver merges into tls:

{
  "username": "bridge",
  "password": "…",
  "ca":   "-----BEGIN CERTIFICATE-----\n…",
  "cert": "-----BEGIN CERTIFICATE-----\n…",
  "key":  "-----BEGIN PRIVATE KEY-----\n…"
}

ca accepts a single PEM or a list, for a chain or during CA rotation. The parser also accepts certPem/certificate and keyPem/privateKey as aliases. Rotating any of it in the store is picked up by the credential refresh path — see Credential rotation.

Two behaviours worth knowing, both fail-closed:

The same pattern works for AMQP 0-9-1, AMQP 1.0 and Azure Service Bus.

Receiver Options

MQTT receivers have no transport-specific options. Subscriptions are declared in the topics[] array on the ReceiverDef, not in the options map.

Every entry is validated at build time – the topic filter against the MQTT v5 filter rules (wildcard placement, $share/<group>/<filter> shape, UTF-8, no null bytes, length) and qos against 0/1/2. Both checks are repeated when the session plan is reconciled, so a plan handed straight to a Session by a library consumer fails the same way. Validating at build matters twice over: a subscription only reaches the broker when the session manager reconciles the plan, so a malformed filter would otherwise fail after the process had started serving – and an out-of-range qos would not fail at all. The MQTT client writes the level as qos & 0x03, so qos: 4 would reach the broker as 0: the route would believe it subscribed at-least-once while the broker delivered at-most-once and never asked for an acknowledgement.

Use the factory for production composition (library-consumer note). Factory.NewReceiver atomically reserves the session’s sole ingress receiver and returns shared.ErrInvalidConfig for a second call, including calls through another factory value or registry alias. The low-level paho.NewReceiver constructor exists for adapter diagnostics and focused router tests; it bypasses factory preflight and must not be used to multiplex production routes onto one session. Receiver IDs remain globally unique in bridge configuration.