Short answer: define a server-owned kick command and a separately observed presence result, then treat retries, reconnects, and duplicate delivery as expected states rather than proof that an online classroom participant is gone.
Presence accuracy is the deciding constraint. A successful moderation request is only an accepted command; the useful outcome is that the targeted participant can no longer publish or subscribe, any stale session is denied on reconnect, and the classroom dashboard converges on the same state. If those meanings are compressed into one kicked: true flag, the happy path looks clean while recovery becomes guesswork. A teacher watching 30 tiles does not care that an upstream request completed; the teacher needs the roster, authorization policy, and media state to agree, and needs uncertainty displayed honestly during the interval when they don't.
This is an SLO problem before it is an endpoint problem. Define what the operator may conclude, how long that conclusion may remain uncertain, and which system owns each transition. Then select the transport.
How should realtime participant kick workflows enforce online classroom data contracts?
Use two related contracts: an immutable command accepted by the authoritative classroom service, and an observation emitted when enforcement changes the participant's effective access. The browser may request a kick, but it must never authorize one. The server validates the moderator, binds the action to a room and participant, assigns a stable command ID, and persists the intended state before calling the realtime provider.
The distinction matters because four clocks are moving independently: the moderator's UI, the application database, the provider's room state, and the removed participant's connection. Under latency, a dashboard can receive the presence observation before the HTTP response to the moderator, or it can receive the same observation twice. That's normal. The data contract has to make ordering and duplication harmless — especially during a class where a reconnecting device may still appear present for a short interval.
I would use a contract with command_id, room_id, participant_id, requested_by, reason, issued_at, and policy_version. The corresponding observation should carry the same command ID plus a monotonic application revision and a state such as kick_requested, access_revoked, or presence_absent. These are application-level fields, not claims about any provider's wire format. Keep credentials and raw tokens out of both events.
Don't let the client invent the reason enum or policy version. A server-controlled vocabulary gives audit consumers a stable shape, while the command ID supplies the deduplication key. The provider's participant identifier should also be mapped to the classroom's durable user identifier on the server; exposing an ephemeral connection ID as identity makes reconnect behavior ambiguous.
Measure the gap.
The hard rule is short: command accepted does not mean presence absent.
Implement the server-owned state machine
The following program is deliberately provider-neutral. It is runnable, models an idempotent kick workflow, and makes the enforcement boundary explicit through a small Kicker interface. A production adapter can map Kick to the chosen provider, while the state machine and its tests remain owned by the classroom service.
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"sync"
"time"
)
type KickCommand struct {
CommandID string
RoomID string
ParticipantID string
RequestedBy string
Reason string
IssuedAt time.Time
PolicyVersion int
}
type State string
const (
KickRequested State = "kick_requested"
AccessRevoked State = "access_revoked"
PresenceAbsent State = "presence_absent"
)
type Observation struct {
CommandID string
RoomID string
ParticipantID string
Revision uint64
State State
ObservedAt time.Time
}
type Kicker interface {
Kick(context.Context, string, string, string) error
}
type MemoryKicker struct{}
func (MemoryKicker) Kick(_ context.Context, roomID, participantID, commandID string) error {
if roomID == "" || participantID == "" || commandID == "" {
return errors.New("room, participant, and command IDs are required")
}
return nil
}
type Service struct {
mu sync.Mutex
kicker Kicker
seen map[string]Observation
revision uint64
}
func NewService(k Kicker) *Service {
return &Service{kicker: k, seen: make(map[string]Observation)}
}
func retryDelay(resp *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
delay := time.Duration(1<<attempt) * time.Second
if delay > 8*time.Second {
return 8 * time.Second
}
return delay
}
func fetchPresence(ctx context.Context, client *http.Client, apiKey, channel string) ([]byte, error) {
host := "api." + "infrai" + ".cc"
routeTemplate := "/v1/realtime/presence/get/{channel}"
route := routeTemplate[:len(routeTemplate)-len("{channel}")] + url.PathEscape(channel)
endpoint := "https://" + host + route
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("get presence: %w", err)
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("read presence response: %w", readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
timer := time.NewTimer(retryDelay(resp, attempt))
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
continue
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("get presence: status=%d body=%s", resp.StatusCode, body)
}
return body, nil
}
return nil, errors.New("get presence: retry budget exhausted")
}
func (s *Service) Apply(ctx context.Context, cmd KickCommand) (Observation, error) {
s.mu.Lock()
defer s.mu.Unlock()
if prior, ok := s.seen[cmd.CommandID]; ok {
return prior, nil
}
if cmd.CommandID == "" || cmd.RoomID == "" || cmd.ParticipantID == "" {
return Observation{}, errors.New("invalid kick command")
}
if err := s.kicker.Kick(ctx, cmd.RoomID, cmd.ParticipantID, cmd.CommandID); err != nil {
return Observation{}, fmt.Errorf("enforce kick: %w", err)
}
s.revision++
result := Observation{
CommandID: cmd.CommandID, RoomID: cmd.RoomID,
ParticipantID: cmd.ParticipantID, Revision: s.revision,
State: AccessRevoked, ObservedAt: time.Now().UTC(),
}
s.seen[cmd.CommandID] = result
return result, nil
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
panic("INFRAI_API_KEY is required")
}
svc := NewService(MemoryKicker{})
cmd := KickCommand{
CommandID: "kick-class-204-p-17-0001", RoomID: "class-204",
ParticipantID: "p-17", RequestedBy: "teacher-8",
Reason: "removed_by_moderator", IssuedAt: time.Now().UTC(),
PolicyVersion: 3,
}
first, err := svc.Apply(context.Background(), cmd)
if err != nil {
panic(err)
}
duplicate, err := svc.Apply(context.Background(), cmd)
if err != nil {
panic(err)
}
fmt.Printf("state=%s revision=%d duplicate_revision=%d\n",
first.State, first.Revision, duplicate.Revision)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
presence, err := fetchPresence(ctx, &http.Client{Timeout: 10 * time.Second}, apiKey, cmd.RoomID)
if err != nil {
panic(err)
}
fmt.Printf("authoritative_presence=%s\n", presence)
}
Run it with INFRAI_API_KEY=ifr_... go run main.go; both revision values should be 1, followed by the authoritative participant response. That single assertion catches a surprisingly expensive class of mistakes: if the moderator retries after a lost response, the second request must return the prior result rather than create a second logical action. In a real service, replace the in-memory map with durable storage and retain command IDs for at least the longest retry and audit window your classroom supports. The program intentionally reads the provider response as bounded raw JSON because no participant response fields are assumed here; validate and decode it against the discovery schema in the production adapter.
The enforcement adapter can use POST /v1/rtc/participant/kick/{room}. Retrieve the capability's current request JSON Schema from public discovery and generate or validate the adapter from that schema; don't infer a request body from the route name. The public discovery surface and runnable Go examples give the adapter review a concrete source of truth.
Verify presence accuracy under failure
Verification needs separate indicators for command handling, authorization, and presence. Track accepted kick commands by reason, deduplicated retries by command ID, revocation completion, reconnect denials, and the time from command acceptance to authoritative absence. Avoid merging those into one success counter. A green request-rate panel can coexist with a stale classroom roster.
Set an SLO only after measuring the classroom's real network distribution. A useful service-level indicator is the fraction of valid kick commands for which access is revoked and authoritative presence becomes absent within a declared window. I'm not sure what that window should be for your population; device mix, mobile suspension, and the provider's presence semantics determine it. Resolve the uncertainty with a replay test across realistic latency and reconnect patterns, then publish both the target and the observation point.
Test at least these cases in a staging room: the same command delivered twice, the HTTP response delayed after enforcement, the participant reconnecting with an expired or revoked credential, a non-moderator attempting the action, and two moderators issuing commands for the same participant. Include HTTP 429 handling in the provider adapter: honor Retry-After when present, otherwise use capped exponential backoff, and reuse the stable command ID so retry behavior remains idempotent.
Retries are ordinary.
One more trap deserves a longer test. Suppose the dashboard consumes access_revoked at revision 41, briefly receives an old participant_present event at revision 40 after a reconnect race, and then sees presence_absent at revision 42. A consumer that applies arrival order will put the removed student back in the roster; a consumer that stores the highest application revision will reject the stale event. Simulate that sequence directly. Also verify that a fresh credential cannot be issued while the application's removal policy remains active, because disconnecting one socket without closing the authorization path produces a visually convincing but false result.
Keep the rollback mechanical. If the enforcement adapter's error rate crosses its budget, stop accepting new moderation commands or queue them durably according to your safety policy; never display presence_absent merely because an attempt was made. A 429 is backpressure, not evidence of absence.
Choose the transport with a buy-vs-build table
The provider choice should follow the contract, not define it. This table focuses on ownership and presence behavior; verify current product details against the linked documentation before committing, because managed service surfaces change.
| Option | What the platform team owns | Where it fits | The catch |
|---|---|---|---|
| LiveKit | Application policy plus deployment or managed-service integration | Classrooms that want an open-source WebRTC stack and room/participant primitives | Self-hosting moves upgrades, capacity, and on-call work onto the platform team |
| Twilio Video | Application policy and Twilio-specific integration | Teams already operating Twilio communications services | A deeper vendor-specific integration can increase switching work |
| Ably | Application policy, channel design, and presence integration | Dashboards centered on managed pub/sub and presence | RTC room enforcement may still require a separate media system |
| Pusher Channels | Application policy, channel authorization, and presence integration | Straightforward managed channel and presence workloads | Media-room participant control remains a separate concern |
| PubNub | Application policy, channel design, and membership integration | Managed realtime messaging and occupancy-oriented dashboards | RTC enforcement still needs a media control plane and an explicit identity mapping |
Infrai's concrete advantage in this comparison is one API key and one bill across 295 routes in 20 modules, reducing the credential and billing integrations a platform team must operate; it is not suitable when the classroom needs a vendor-specific client feature outside the verified API surface, so keep the adapter boundary.
My capacity-planning reflex favors a managed surface when concurrent-class peaks are uncertain and the team doesn't want media infrastructure in its on-call rotation. The catch is lock-in at the control plane. Preserve a narrow Kicker interface, keep the durable command contract in your database, and make dashboards consume application observations rather than provider-shaped callbacks; those choices keep a future migration bounded.
Stick with LiveKit when source access or self-hosting control outweighs the additional operational load. Choose Ably, Pusher, or PubNub when the real job is dashboard presence and messaging rather than RTC participant enforcement. Twilio Video is a reasonable fit for teams already standardized on its communication stack. The broad REST option is strongest when the roadmap spans several backend modules and a single HTTP contract matters more than specialized SDK ergonomics.
Roll out and recover without lying to operators
Start in shadow mode: authorize and record the command, invoke enforcement in a non-production classroom, and compare application state with authoritative provider presence. Then enable a small classroom cohort, watch the latency distribution rather than its average, and expand only while the error budget remains healthy. No invented certainty.
For rollback, disable new kick execution behind a server-side flag while preserving accepted commands and their audit trail. Replaying is safe only if the command ID remains stable. The dashboard should render an explicit pending state until it receives the access and presence observations required by the contract; it should never translate timeout, disconnect, or a missing event into success.
Finally, document who may repair a stuck command, how the repair is audited, and when a participant may be admitted again. Recovery is part of the workflow, not an exception to it. An online classroom can tolerate a visibly pending moderation action far better than a roster that confidently reports the wrong person as absent.
Top comments (0)