6.0 KiB
ADR 0010: typed connection outcomes, deadlines, and caller-owned fallback
- Status: Accepted
- Date: 2026-07-16
- Tracking: #13
Context
A connection can stop in the directory, authorization, mediation, NAT traversal, or direct-connection phase. Those failures have different authorities: an HTTP response can authoritatively reject a join, the SDK can observe a local timeout, and only the remote host can reject a direct connection. Treating all of them as one message or generic timeout would make player guidance, retry policy, tests, and operational measurements unreliable.
UDP loss, service silence, cancellation, and late LiteNetLib callbacks also make completion races unavoidable. Games need one terminal result and bounded work, not a sequence of contradictory callbacks. Direct traversal cannot be guaranteed, but v1 has no gameplay relay and must not imply otherwise.
Decision
Closed typed outcome model
ConnectionOutcomeKind is the stable wire-level terminal set: connected,
cancelled, directory not found, attempt expired, incompatible protocol,
unauthorized, rate limited, no host presence, service unavailable or rejected,
mediator unavailable, punch timeout, direct-connect timeout, host rejection,
transport error, manager stopped, and disposed.
The already-frozen v1 members TimedOut, StaleHost, TransportFailed, and
FallbackOffered retain their original numeric values for source and wire
compatibility. New SDK code never emits them. The report service accepts them,
normalizes the first three to their precise modern equivalents, and does not let
legacy compatibility weaken the typed coordinator result.
The client adds RendezvousConnectionOutcomeSource, failure category, and phase.
These fields preserve authority instead of guessing from text:
RendezvousServiceis used only for an HTTP decision or bounded service silence. Its optionalServiceErrorretains the stable service error code.LocalTraversalreports local punch, direct-connect, and transport observations.RemoteHostreports an explicit direct-connection rejection.CallerandLifecycledistinguish cancellation from manager shutdown or disposal.
Messages remain diagnostic and are never parsed into outcomes. A successful NAT
introduction is only a transition to direct connection; Connected is emitted
only after LiteNetLib reports the authenticated peer connected.
Join issuance is exposed as RendezvousConnectionStartResult, containing exactly
one issued attempt or one terminal service outcome. Once an attempt is issued,
the coordinator owns its local terminal outcome. Completion is exactly once;
terminal paths release SDK subscriptions so late introductions, peer callbacks,
network errors, cancellation, and polling are inert.
Bounded phases and retries
Each HTTP try has a five-second default silence budget, configurable from above
zero through thirty seconds. Only safe operations use the existing bounded retry
policy, honoring caller cancellation and server retry guidance. Exhausting that
budget returns ServiceUnavailable; it never waits indefinitely.
Traversal has independent defaults: ten seconds for punch/mediation and five seconds for the direct connection. Both are configurable up to thirty seconds. Local budgets, retry schedules, and elapsed duration use monotonic time, so a wall-clock correction cannot extend them or produce a negative duration. The signed attempt expiry is converted to an additional monotonic upper bound when the attempt is received. Punch retries retain their bounded request count and exponential backoff; crossing a phase deadline completes exactly once even if a delayed packet later arrives. Tests use an injected clock and do not depend on wall-clock sleeps.
Explicit dedicated fallback handoff
A publisher may attach one validated dedicated endpoint to registration or
update only when the tenant's provisioned fallback policy allows it. The server
copies that endpoint into browser and issued-attempt contracts.
The client coordinator defensively copies it into every terminal outcome; a game
may override it locally through DedicatedFallbackOverride.
The SDK never opens, dials, reserves, probes, or authenticates the fallback. The game decides whether the outcome permits fallback, presents any player choice, and connects through its own gameplay transport and admission rules. Absence of an endpoint is an honest no-fallback result. Gameplay relay is absent from v1.
Privacy-safe optional reporting
After an issued attempt completes, the game may explicitly report its outcome
with the short-lived client punch capability. Reporting is authenticated and
idempotent: an exact repeat succeeds as a duplicate, while a conflicting repeat
is rejected. Reports contain only an allowlisted outcome enum and one coarse
elapsed bucket (<1s, 1–5s, 5–15s, 15–30s, or 30s+). They contain no
diagnostic message, exact duration, endpoint, metadata, player identifier, or
credential.
Frozen v1 DTOs still expose elapsedMilliseconds and diagnosticCode. They are
deprecated compatibility inputs: the current SDK omits them, the service
immediately buckets legacy elapsed time, and neither exact timing nor diagnostic
text is retained, logged, or used as a metric dimension.
The store retains a bounded capability-fingerprint tombstone long enough to accept a report after the live attempt expires. Metrics count the first accepted outcome only and use only outcome plus elapsed bucket as dimensions. Service issuance failures cannot be reported because no attempt capability was issued.
Consequences
- Player-facing UI can map stable outcome/category pairs to localized guidance without exposing diagnostic strings.
- Service rejection, remote-host rejection, and local observation remain distinguishable for retry and support decisions.
- Games own fallback policy and gameplay admission; Rendezvous does not claim a guaranteed connection path.
- Outcome additions are contract changes and require OpenAPI, serialization, public API, fake-clock, late-event, and idempotency coverage.