feat(client): standardize connection outcomes (#13)
quality-gate / quality (push) Successful in 59s

This commit is contained in:
KyuubiYoru
2026-07-16 10:18:41 +02:00
parent b4b6072fe1
commit 94aba8a3bb
47 changed files with 2727 additions and 178 deletions
@@ -0,0 +1,117 @@
# 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:
- `RendezvousService` is used only for an HTTP decision or bounded service
silence. Its optional `ServiceError` retains the stable service error code.
- `LocalTraversal` reports local punch, direct-connect, and transport
observations.
- `RemoteHost` reports an explicit direct-connection rejection.
- `Caller` and `Lifecycle` distinguish 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`, `15s`, `515s`, `1530s`, 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.
+1
View File
@@ -12,6 +12,7 @@ decision requires a superseding ADR and corresponding contract/test updates.
- [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md)
- [ADR 0008: scoped join attempts and one-time connection tickets](0008-scoped-join-attempts-and-tickets.md)
- [ADR 0009: authenticated bounded LiteNetLib NAT mediator](0009-authenticated-litenet-nat-mediator.md)
- [ADR 0010: typed connection outcomes, deadlines, and caller-owned fallback](0010-typed-connection-outcomes-and-fallback.md)
- [Threat model](../security/threat-model.md)
- [Security promise and test matrix](../security/control-matrix.md)
- [Versioned HTTP and UDP contracts](../contracts/README.md)