Repository navigation
docs: add Socket Mode design doc #689
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
19b624d
9f8410a
0c69475
f43b7b2
de6734c
f1358b6
ab65960
7074f9a
35ad949
c5ee604
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| # Socket Mode Design | ||
|
|
||
| > See [SocketMode-Flow-Walkthrough.md](./SocketMode-Flow-Walkthrough.md) for | ||
| > an implementation-level walkthrough with sequence diagrams covering | ||
| > startup, steady-state dispatch, token rotation, and reconnect flows, and | ||
| > [SocketMode-Hosting-Integration.md](./SocketMode-Hosting-Integration.md) | ||
| > for how Socket Mode plugs into DI registration, host startup, and the | ||
| > shared `TeamsBotApplication` activity pipeline. | ||
|
|
||
| ## Overview | ||
|
|
||
| Socket Mode lets a bot receive activities over outbound WebSocket connections | ||
| instead of exposing an inbound HTTP endpoint. The bot dials out to the Bot | ||
| Framework service (SignalR-based), so no public URL needs to be reachable from | ||
| Teams. | ||
|
|
||
| Socket Mode is supported **only in the public cloud**. It is an **experimental | ||
| feature**, marked with `[Experimental("ExperimentalTeamsSocketMode")]`, and is | ||
| recommended only for developing and testing agents locally or in constrained | ||
| network environments -- not for production workloads. | ||
|
|
||
| ## Architecture | ||
|
|
||
| Socket Mode maintains **one connection per geo**. The default geos are `amer`, | ||
| `emea`, and `apac`. | ||
|
|
||
| ```mermaid | ||
| graph TB | ||
| Host["SocketModeHostedService (IHostedService)"] --> Transport["SocketModeTransport (IGeoSocketOwner)"] | ||
| Transport --> GeoA["GeoSocket: amer"] | ||
| Transport --> GeoB["GeoSocket: emea"] | ||
| Transport --> GeoC["GeoSocket: apac"] | ||
| GeoA --> ConnA["SocketModeConnection"] | ||
| GeoB --> ConnB["SocketModeConnection"] | ||
| GeoC --> ConnC["SocketModeConnection"] | ||
| ConnA --> App["TeamsBotApplication.ProcessWithInvokeResponseAsync"] | ||
| ConnB --> App | ||
| ConnC --> App | ||
| ``` | ||
|
|
||
| Each geo is managed independently by a `GeoSocket` supervisor, which: | ||
|
|
||
| - Establishes an initial ready connection within a startup budget | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i would mention here that any geo failing during startup fails startup for ALL geos (and retries w/ backoff). the third bullet about not affecting other geos is only true after startup |
||
| (`StartupTimeout`, default 30s). | ||
| - Supervises the connection in the background for the lifetime of the host. | ||
| - Reconnects after unexpected closures, without affecting other geos. | ||
|
|
||
| A `SocketModeTransport` owns all configured `GeoSocket` instances (one per | ||
| geo) and implements `IGeoSocketOwner`. It supplies the shared retry/backoff | ||
| policy to each `GeoSocket` and dispatches inbound activities into the app's | ||
| pipeline. | ||
|
|
||
| `SocketModeHostedService` is an `IHostedService` that starts the transport. | ||
| Host startup does not complete until every configured geo has an initial ready | ||
| connection; if a geo fails to become ready within its startup budget, the | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i see its mentioned here so maybe u could add the part about it failing the startup for all geos and keep it here |
||
| failure surfaces out of `host.Run()` instead of failing silently in the | ||
| background. | ||
|
|
||
| ## Negotiate flow | ||
|
|
||
| Before opening a WebSocket, each `GeoSocket` calls a negotiate HTTP endpoint | ||
| (`SocketModeNegotiator`) to obtain SignalR connection details: | ||
|
|
||
| 1. The negotiate URL is `NegotiateBaseUrl` (default | ||
| `https://botapi.skype.com`) plus a geo-specific path segment. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not sure if it would go here but we do override this URL depending on the testing ring (we currently override for Canary, and next we'll need a different URL for R0) |
||
| 2. The request is authenticated with the bot's own app token, acquired via the | ||
| standard client-credentials flow with scope | ||
| `https://api.botframework.com/.default` -- the same credential the bot | ||
| already uses for outbound Bot Framework API calls. | ||
| 3. The response provides the actual SignalR connection info (endpoint URL and | ||
| access token) used to open the WebSocket. | ||
|
|
||
| Socket Mode is restricted to the public cloud. This is enforced by checking | ||
| the bot's token issuer: it must be `https://api.botframework.com`. If the | ||
| configured cloud is not the public cloud, registration throws | ||
| `InvalidOperationException` rather than attempting to negotiate. | ||
|
|
||
| ## Reconnect & token rotation | ||
|
|
||
| Connections are proactively rotated **make-before-break** ahead of token | ||
| expiry (`TokenRefreshMargin`): | ||
|
|
||
| 1. A new-generation connection negotiates and connects while the current one | ||
| keeps serving traffic. | ||
| 2. Once the new connection reports ready, the old connection is retired. | ||
| 3. `HandoffWindow` controls how long the retiring connection keeps dispatching | ||
| in-flight activities before it is torn down, so no activity is dropped | ||
| during rotation. | ||
|
|
||
| On an **unexpected disconnect** (not a planned rotation), the `GeoSocket` | ||
| reconnects using one of two strategies: | ||
|
|
||
| - An explicit `ReconnectDelays` schedule, if configured -- the last delay in | ||
| the list repeats for subsequent attempts. | ||
| - Capped exponential backoff with jitter, if no explicit schedule is | ||
| configured. | ||
|
|
||
| Negotiate failures that return HTTP `401`/`403` are treated as **non-retryable | ||
| auth failures** (`SocketModeNegotiateException.IsNonRetryable`). In that case | ||
| the geo stops permanently until the app restarts, rather than retrying | ||
| forever, and `OnGeoDisconnected` is raised so the host/app can observe and log | ||
| the failure. | ||
|
MehakBindra marked this conversation as resolved.
|
||
|
|
||
| Each geo's failure is isolated: a permanently failed or reconnecting geo does | ||
| not affect the other configured geos, which keep running independently. | ||
|
|
||
| ## Dispatch & invoke responses | ||
|
|
||
| Inbound activities received over a socket are handed to the app's normal | ||
| processing pipeline via: | ||
|
|
||
| ```csharp | ||
| await teamsBotApplication.ProcessWithInvokeResponseAsync( | ||
| activity, | ||
| user: null, | ||
| correlationVector: null, | ||
| cancellationToken); | ||
| ``` | ||
|
|
||
| There is no per-activity principal -- the socket connection itself is already | ||
| authenticated with the bot's own app token, unlike the HTTP path where each | ||
| request carries its own bearer token. | ||
|
|
||
| Because there is no `HttpContext` in Socket Mode, the invoke response (status | ||
| code + body) that handlers normally write to the HTTP response is instead | ||
| captured through an `AsyncLocal` capture object scoped to the turn. After the | ||
| pipeline completes, the captured status and body are serialized and sent back | ||
| over the socket as the reply frame for that activity. | ||
|
|
||
| > **Note:** `AsyncLocal` is used deliberately, not as a default choice. The | ||
| > base `BotApplication.OnActivity` delegate is a public, `Task`-returning | ||
| > extensibility point shared with the HTTP transport, so its signature can't | ||
| > be widened to return a value without a breaking API change. `AsyncLocal` | ||
| > gives each concurrent turn an isolated slot without touching that contract. | ||
| > The capture is private to `TeamsBotApplication`, has exactly one writer and | ||
| > one reader, and is opened/closed within a single `try`/`finally` around one | ||
| > `ProcessAsync` call, which avoids the usual debugging pitfalls (stale | ||
| > values from lost `ExecutionContext` flow, unclear ownership) as long as | ||
| > that scoping discipline is preserved. | ||
|
|
||
| ## Options (`SocketModeOptions`) | ||
|
|
||
| | Option | Default | Purpose | | ||
| |---|---|---| | ||
| | `NegotiateBaseUrl` | `https://botapi.skype.com` | Base URL for the per-geo negotiate call | | ||
| | `Geos` | `amer`, `emea`, `apac` | Geos to connect to; one `GeoSocket` per entry | | ||
| | `StartupTimeout` | 30s | Budget for a geo's initial connection to become ready during host startup | | ||
| | `ReconnectDelays` | none (exponential backoff) | Optional explicit reconnect delay schedule; last value repeats | | ||
| | `ReadinessTimeout` | 30s | Time allowed for a new connection (initial or rotated) to report ready | | ||
| | `KeepAliveInterval` | 15s | Interval for client-side keep-alive pings on the socket | | ||
| | `ServerTimeout` | 30s | Time without any server message before the connection is considered lost | | ||
|
|
||
| Options are **validated when the host starts** (inside | ||
| `SocketModeHostedService`), not at registration time -- registration only | ||
| records the configuration, so misconfiguration is reported alongside other | ||
| startup failures rather than during DI setup. | ||
|
|
||
| ## Usage | ||
|
|
||
| Socket Mode requires a host **without** a web server, since it has no inbound | ||
| HTTP endpoint to map. Build the host with `Host.CreateApplicationBuilder()` | ||
| instead of `WebApplication.CreateBuilder()`: | ||
|
|
||
| ```csharp | ||
| var builder = Host.CreateApplicationBuilder(args); | ||
|
|
||
| builder.Services.AddTeamsBotApplication(options => | ||
| { | ||
| options.UseSocketMode(); | ||
| // or with configuration: | ||
| // options.UseSocketMode(o => | ||
| // { | ||
| // o.Geos = ["amer", "emea"]; | ||
| // o.StartupTimeout = TimeSpan.FromSeconds(45); | ||
| // }); | ||
| }); | ||
|
MehakBindra marked this conversation as resolved.
|
||
|
|
||
| var host = builder.Build(); | ||
| host.UseTeamsBotApplication(); | ||
|
|
||
| host.Run(); | ||
| ``` | ||
|
|
||
| `host.UseTeamsBotApplication()` is an `IHost` overload that wires up | ||
| `SocketModeHostedService` instead of mapping an HTTP route. | ||
|
|
||
| Mixing Socket Mode with a web server is rejected: calling the | ||
| `WebApplication` / `IEndpointRouteBuilder` overloads of | ||
| `UseTeamsBotApplication()`, or building with `WebApplication.CreateBuilder()` | ||
| while Socket Mode is enabled, throws `InvalidOperationException` with a | ||
| message pointing at the correct host type to use instead. | ||
|
|
||
| Calling `options.UseSocketMode(false)` clears Socket Mode configuration and | ||
| falls back to the normal HTTP endpoint behavior. | ||
|
|
||
| ## Sample | ||
|
|
||
| `samples/SocketModeBot` will contain a working end-to-end example once the | ||
| app-integration PR stack lands, showing the `Host.CreateApplicationBuilder()` | ||
| setup, `UseSocketMode()` configuration, and `host.UseTeamsBotApplication()` | ||
| wiring described above. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| # Socket Mode Flow Walkthrough | ||
|
|
||
| > This is a single, flow-oriented walkthrough of the Socket Mode transport, | ||
| > replacing three earlier per-class walkthrough docs. Instead of going | ||
| > class-by-class, it follows four concrete sequences end-to-end: **startup**, | ||
| > **steady-state dispatch**, **make-before-break token rotation**, and | ||
| > **unexpected disconnect / reconnect**. See | ||
| > [SocketMode-Design.md](./SocketMode-Design.md) for the user-facing design | ||
| > and options. | ||
|
|
||
| ## Component map | ||
|
|
||
| ``` | ||
| SocketModeHostedService (IHostedService; blocks host startup until every geo is ready) | ||
| │ | ||
| ▼ | ||
| SocketModeTransport (implements IGeoSocketOwner; one GeoSocket per configured geo) | ||
| │ | ||
| ▼ | ||
| GeoSocket (per-geo supervisor: generations, rotation, retry/backoff) | ||
| │ ISocketConnectionFactory.Create(...) | ||
| ▼ | ||
| SignalRSocketConnectionFactory / SignalRSocketConnection | ||
| │ negotiates via SocketModeNegotiator, then builds | ||
| ▼ | ||
| SignalRClientConnection (thin adapter over HubConnectionBuilder) | ||
| │ | ||
| ▼ | ||
| Microsoft.AspNetCore.SignalR.Client.HubConnection | ||
| ``` | ||
|
|
||
| `ISocketConnection` / `ISocketConnectionFactory` / `SocketConnectionHandlers` | ||
| (all in `SocketModeConnection.cs`) are the interfaces that let `GeoSocket` | ||
| depend on "some connection generation" without knowing SignalR exists. | ||
|
|
||
| ## 1. Startup | ||
|
|
||
| `SocketModeHostedService` doesn't finish starting until **every** geo has a | ||
| ready connection (within `StartupTimeout`, default 30s). A failure here | ||
| surfaces from `host.Run()`. | ||
|
|
||
| ```mermaid | ||
| sequenceDiagram | ||
| participant Host as SocketModeHostedService | ||
| participant Transport as SocketModeTransport | ||
| participant Geo as GeoSocket (per geo) | ||
| participant Factory as SignalRSocketConnectionFactory | ||
| participant Conn as SignalRSocketConnection | ||
| participant Neg as SocketModeNegotiator | ||
| participant Client as SignalRClientConnection | ||
| participant Hub as HubConnection | ||
|
|
||
| Host->>Transport: StartAsync() | ||
| par for each configured geo | ||
| Transport->>Geo: StartAsync() | ||
| Geo->>Geo: ConnectAsync() (generation 1) | ||
| Geo->>Factory: Create(negotiateUri, handlers) | ||
| Factory->>Conn: new SignalRSocketConnection | ||
| Geo->>Conn: StartAsync() | ||
| Conn->>Neg: NegotiateAsync(negotiateUri) | ||
| Neg-->>Conn: url, accessToken, expiresIn | ||
| Conn->>Client: CreateSignalRClientConnection(url, token, keepAlive, serverTimeout) | ||
| Conn->>Client: OnActivity / OnReady / OnClosed | ||
| Conn->>Client: StartAsync() | ||
| Client->>Hub: HubConnectionBuilder...Build().StartAsync() | ||
| Hub-->>Client: SocketReady frame | ||
| Client-->>Conn: OnReady(frame) | ||
| Conn-->>Conn: TokenLifetime set, readySource resolved | ||
| Conn-->>Geo: StartAsync() returns | ||
| Geo->>Geo: TryPromote(generation 1) -> active | ||
| Geo-->>Transport: geo ready | ||
| end | ||
| Transport-->>Host: all geos ready -> StartAsync() returns | ||
| ``` | ||
|
|
||
| If a geo's `ConnectAsync` fails to reach ready within `StartupTimeout` (e.g. | ||
| negotiate 401/403, or `ReadinessTimeout` elapses waiting for `SocketReady`), | ||
| that failure propagates up through `Transport.StartAsync()` and | ||
| `Host.StartAsync()`, so the whole host fails to start rather than running | ||
| with a silently-missing geo. | ||
|
|
||
| ## 2. Steady-state dispatch | ||
|
|
||
| Once a generation is active, inbound activity envelopes flow straight | ||
| through to the existing `TeamsBotApplication` pipeline -- there is no | ||
| parallel activity-processing framework. | ||
|
|
||
| ```mermaid | ||
| sequenceDiagram | ||
| participant Hub as HubConnection | ||
| participant Client as SignalRClientConnection | ||
| participant Conn as SignalRSocketConnection | ||
| participant Geo as GeoSocket | ||
| participant Transport as SocketModeTransport | ||
| participant App as TeamsBotApplication | ||
|
|
||
| Hub->>Client: "Activity" hub invocation (envelope) | ||
| Client->>Conn: OnActivity handler | ||
| Conn->>Conn: await readySource.Task (already resolved) | ||
| Conn->>Geo: handlers.OnActivity(envelope) | ||
| Geo->>Geo: only the active (non-retiring) generation dispatches | ||
|
MehakBindra marked this conversation as resolved.
|
||
| Geo->>Transport: DispatchAsync(envelope) | ||
| Transport->>App: ProcessWithInvokeResponseAsync(activity, user: null, correlationVector: null) | ||
| App-->>Transport: invoke response (status + body) via AsyncLocal capture | ||
| Transport-->>Geo: reply frame | ||
| Geo-->>Conn: reply frame | ||
| Conn-->>Client: return value from OnActivity handler | ||
| Client-->>Hub: reply frame returned to server | ||
| ``` | ||
|
|
||
| No per-activity principal is attached -- the socket connection itself is | ||
| already authenticated with the bot's app token. The invoke response is | ||
| captured via an `AsyncLocal` object instead of `HttpContext`, since there is | ||
| no HTTP context in Socket Mode. | ||
|
|
||
| ## 3. Make-before-break token rotation | ||
|
|
||
| Connections are rotated **before** their token expires (`TokenRefreshMargin` | ||
| before `TokenLifetime` runs out), so there's no gap where the active | ||
| connection is unauthenticated. | ||
|
|
||
| ```mermaid | ||
| sequenceDiagram | ||
| participant Geo as GeoSocket | ||
| participant NewConn as Generation N+1 | ||
| participant OldConn as Generation N (retiring) | ||
|
|
||
| Note over Geo: ScheduleRefresh timer fires at TokenLifetime - TokenRefreshMargin | ||
| Geo->>Geo: RequestRotation() | ||
| Geo->>NewConn: ConnectAsync() (same negotiate/connect/ready sequence as startup) | ||
| NewConn-->>Geo: ready | ||
| Geo->>Geo: TryPromote(N+1) -> N+1 becomes active | ||
| Geo->>OldConn: StartRetirement(N) | ||
| Note over OldConn: N keeps dispatching for up to HandoffWindow | ||
| Geo->>OldConn: RetireAsync(N) once HandoffWindow elapses | ||
| OldConn->>OldConn: StopAsync() / DisposeAsync() | ||
| ``` | ||
|
|
||
| During the handoff window there are briefly **two** live generations for the | ||
| same geo: the new one is already active for new dispatch, while the old one | ||
| finishes out its window so in-flight requests aren't dropped mid-rotation. | ||
|
|
||
| ## 4. Unexpected disconnect & reconnect | ||
|
|
||
| Unplanned closures (network blips, server-initiated drops) are distinguished | ||
| from planned ones (rotation, shutdown) via the `planned` flag on | ||
| `OnClosed(error, planned)`. | ||
|
|
||
| ```mermaid | ||
| sequenceDiagram | ||
| participant Hub as HubConnection | ||
| participant Client as SignalRClientConnection | ||
| participant Conn as SignalRSocketConnection | ||
| participant Geo as GeoSocket | ||
| participant Transport as SocketModeTransport | ||
|
|
||
| Hub--xClient: connection drops unexpectedly | ||
| Client-->>Conn: HubConnection.Closed | ||
| Conn->>Conn: HandleClosed(error) -- planned = (_stopped != 0) = false | ||
| Conn-->>Geo: handlers.OnClosed(error, planned: false) | ||
| Geo->>Geo: SuperviseAsync: compute delay (ReconnectDelays schedule, or capped exponential backoff + jitter) | ||
| Geo->>Geo: after delay, ConnectAsync() (next generation) | ||
| alt negotiate returns 401/403 | ||
| Geo->>Geo: SocketModeNegotiateException.IsNonRetryable = true | ||
| Geo->>Geo: stop retrying this geo permanently (until app restart) | ||
| Geo-->>Transport: OnGeoDisconnected(geo) | ||
| else negotiate/connect succeeds | ||
| Geo->>Geo: TryPromote(new generation) -> active again | ||
| end | ||
| ``` | ||
|
|
||
| Each geo is isolated: one geo permanently failing (non-retryable auth | ||
| failure) does not affect the others, which keep running and reconnecting | ||
| independently. | ||
|
|
||
| ## Class responsibilities (quick reference) | ||
|
|
||
| | Class | Responsibility | Status | | ||
| |---|---|---| | ||
| | `SocketModeHostedService` | `IHostedService`; blocks host startup until every geo is ready | PR #686 | | ||
| | `SocketModeTransport` | Implements `IGeoSocketOwner`; owns all `GeoSocket`s; dispatches to `TeamsBotApplication` | PR #686 | | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can update all of these to |
||
| | `GeoSocket` | Per-geo supervisor: generations, make-before-break rotation, retry/backoff, non-retryable failure isolation | PR #686 | | ||
| | `SignalRSocketConnectionFactory` / `SignalRSocketConnection` | One connection generation's lifecycle: negotiate, readiness gate, `TokenLifetime` | `main` | | ||
| | `SignalRClientConnection` | Thin adapter over `HubConnectionBuilder`; the only class that touches it directly | `main` | | ||
| | `SocketModeConnection.cs` (`ISocketConnection`, `ISocketConnectionFactory`, `SocketConnectionHandlers`) | Interface seam between `GeoSocket` and the SignalR-backed implementation; no SignalR code | `main` | | ||
| | `SocketModeNegotiator` | Negotiates SignalR connection info against the negotiate HTTP endpoint | `main` | | ||
Uh oh!
There was an error while loading. Please reload this page.