DEV Community

ValtorMist7692
ValtorMist7692

Posted on

Realtime Participant Kick Workflows: How to Enforce Online Classroom Data Contracts

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

References

Top comments (0)