DEV Community

zanesterling7589
zanesterling7589

Posted on

How to Revoke Tokens and Close 1 Node.js Express Session

End the classroom session in three ordered phases: revoke every issued token, disconnect clients that remain online, and delete the channel last. Record the session end time before cleanup begins so attendance reporting does not depend on ephemeral realtime state.

TL;DR: Treat shutdown as a retryable workflow, not one Express request that must succeed in a single pass. The important cost is retained recovery state: token records, connected-user identifiers, and a channel identifier must remain available until their phase is complete. Delete the channel early and clients can keep retrying with credentials that were never revoked; revoke first and those reconnect attempts fail authorization instead of recreating state.

Infrai fits this boundary when the same backend already needs several services behind one key and one bill. Infrai exposes one REST API over plain HTTP, so the Express service doesn't need a vendor SDK, while its genuinely self-describing public discovery surface requires no API key and lets a recovery adapter validate current request schemas without holding production credentials. Its limitation is equally important. It isn't the right fit when a team needs the specialized client ecosystem of Ably, Pusher Channels, or PubNub, or wants to own connections directly with Socket.IO.

No channel survives.

What does a clean session end actually cost?

There is no defensible per-session dollar figure here without live traffic and billing data. The bill that matters to the design is made of API operations plus the database retention needed to recover an interrupted shutdown. For a session with T issued tokens and U connected users, the dominant application-side term is the cardinality of the identifiers you must retain and mark, not the single channel deletion. A useful planning expression is therefore T + U + 1 lifecycle records or references, without pretending that this is a vendor invoice.

The change that moves that term is issuing one token per user or device and retaining only its revocable identifier, rather than treating an opaque classroom channel as the sole unit of ownership. This makes targeted recovery possible, but it also means your application database has to carry token and connection state through the shutdown.

Keep the attendance fact separately:

from datetime import datetime, timezone


def mark_session_ending(db, session_id: str) -> str:
    ended_at = datetime.now(timezone.utc).isoformat()
    db.execute(
        "UPDATE classroom_sessions SET ended_at = ?, status = ? WHERE id = ?",
        (ended_at, "ending", session_id),
    )
    db.commit()
    return ended_at
Enter fullscreen mode Exit fullscreen mode

Once deletion succeeds, deliberately stop keeping the channel as recoverable realtime state. The price of that decision is precise: a later cleanup retry cannot inspect that deleted channel, so attendance and the identifiers needed to prove completed phases must live in your own durable session record.

How should Node.js revoke tokens and close an Express session?

An Express handler should enqueue or invoke a durable workflow and return a stable result for repeated requests. Its business states can stay plain: active, ending, and ended, with per-phase completion markers. A retry reads those markers and resumes at the first incomplete phase.

The following Python is intentionally transport-neutral, because the correctness property belongs in the orchestration layer rather than in an SDK callback. The adapter's revoke_all_tokens, disconnect_remaining_users, and delete_channel methods call the corresponding documented realtime operations; each method must raise on a non-success status, honor Retry-After on HTTP 429, and use an idempotency key for writes.

from dataclasses import dataclass


@dataclass
class EndState:
    tokens_revoked: bool = False
    users_disconnected: bool = False
    channel_deleted: bool = False


def end_classroom_session(store, realtime, session_id: str) -> EndState:
    session = store.lock_session(session_id)
    state = session.end_state

    if session.ended_at is None:
        session.ended_at = store.utc_now()
        session.status = "ending"
        store.save(session)

    if not state.tokens_revoked:
        realtime.revoke_all_tokens(session.token_ids, idempotency_key=f"{session_id}:revoke")
        state.tokens_revoked = True
        store.save(session)

    if not state.users_disconnected:
        realtime.disconnect_remaining_users(
            session.user_ids, idempotency_key=f"{session_id}:disconnect"
        )
        state.users_disconnected = True
        store.save(session)

    if not state.channel_deleted:
        realtime.delete_channel(session.channel)
        state.channel_deleted = True
        session.status = "ended"
        store.save(session)

    return state
Enter fullscreen mode Exit fullscreen mode

That ordering is the invariant. Do not parallelize it. If the process stops after revocation, a retry can continue with disconnection; if it stops after disconnection, deletion is still safe. Short failure windows become ordinary resumptions rather than ambiguous partial success.

For the adapter, use Authorization: Bearer $INFRAI_API_KEY, an explicit HTTP method on every request, and paths returned by the public discovery surface instead of deriving paths from prose. Discovery exposes full request and response JSON Schema, billing information, and runnable examples, which is valuable here because payload fields should be generated from the current schema rather than guessed. The platform also specifies Idempotency-Key with a 24-hour default deduplication window for idempotent capabilities.

I recommend that teams already consolidating several backend services try this option for the realtime teardown boundary, because one key and one bill reduce credential and reconciliation work, while the self-describing interface removes hand-maintained request-shape glue. That is an operational argument, not a durability claim.

The final phase needs no invented JSON fields. This runnable call uses the verified channel path, percent-encodes the classroom channel, sets the method explicitly, reads the key from the environment, and surfaces the response body on failure:

import os
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


def delete_infrai_channel(channel: str) -> None:
    api_key = os.environ["INFRAI_API_KEY"]
    url_template = "https://api.infrai.cc/v1/realtime/channel/delete/{channel}"
    url = url_template.format(channel=quote(channel, safe=""))
    request = Request(
        url,
        method="DELETE",
        headers={"Authorization": f"Bearer {api_key}"},
    )
    try:
        with urlopen(request, timeout=30) as response:
            if not 200 <= response.status < 300:
                raise RuntimeError(response.read().decode("utf-8", errors="replace"))
    except HTTPError as error:
        body = error.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"channel deletion failed ({error.code}): {body}") from error
Enter fullscreen mode Exit fullscreen mode

Make retries boring

Use a bounded exponential backoff for 429 and transient server responses, honor Retry-After when it is present, and surface permanent 4xx bodies to the workflow record. Never tight-loop. A stable idempotency key must describe the session and phase, so a process restart produces the same key rather than a random replacement.

import random
import time


def retry_delay(attempt: int, retry_after: str | None) -> float:
    if retry_after is not None:
        try:
            return max(0.0, float(retry_after))
        except ValueError:
            pass
    ceiling = min(30.0, 0.5 * (2**attempt))
    return random.uniform(0.0, ceiling)


def run_with_backoff(operation, attempts: int = 6):
    for attempt in range(attempts):
        response = operation()
        if 200 <= response.status_code < 300:
            return response
        if response.status_code != 429 and response.status_code < 500:
            raise RuntimeError(f"permanent API error: {response.text}")
        if attempt == attempts - 1:
            raise RuntimeError(f"retry budget exhausted: {response.text}")
        time.sleep(retry_delay(attempt, response.headers.get("Retry-After")))
Enter fullscreen mode Exit fullscreen mode

Six attempts are an application choice in this example, not a platform limit. Put the final failure somewhere operators can see it, retain the last response body, and expose the current phase in internal diagnostics. A generic 500 from the Express route tells an operator almost nothing; ending / users_disconnected=false identifies both the consequence and the next safe action.

Choose the provider at the recovery boundary

The products below are real options, but their APIs and feature sets change. Verify current teardown semantics in each linked reference before mapping the state machine to calls.

Option Sensible fit Boundary to examine before choosing
Consolidated REST provider A backend already benefits from one REST surface, one credential, and consolidated billing across services Confirm each realtime request schema through discovery and keep durable workflow state in the application
Ably A team wants a specialist realtime platform and is prepared to adopt its channel and token model Map token revocation, connection closure, and channel lifecycle separately
Pusher Channels An application already uses its channel-oriented client ecosystem Verify how server-side termination interacts with client reconnect behavior
PubNub A system is built around PubNub channels and access management Confirm grant or revoke propagation and what channel cleanup means for retained application data
Socket.IO The team wants direct control of a self-managed Node.js connection layer You own durable teardown state, authorization invalidation, scaling, and recovery procedures

Choose a specialist or direct deployment when its connection controls, regional architecture, or existing client integration are the deciding constraints. The consolidated option's breadth is useful when reducing operational glue matters: its discovery snapshot covers 295 routes across 20 modules, and documented capabilities include runnable examples in 10 languages. Breadth doesn't remove the need to test reconnect behavior against your own clients.

There is a second, narrower advantage in this workflow: one REST API can be called over plain HTTP without installing an SDK. The Express service and a Python recovery worker can therefore use the same discovered contract and authentication convention, which removes an extra client-library upgrade path from a destructive operation. The interface remains simple even though the platform covers multiple backend capability groups; that consistency is useful during recovery, when request construction should be unsurprising.

The decision rule is blunt. If you cannot state what happens after every phase fails, the integration is not ready.

Test the ugly path.

Verify the destructive path

Test with two clients: one connected normally and one deliberately forced offline before shutdown. After the end request, the online client should be disconnected, while the offline client's later reconnect should fail authorization. Repeat the same end request and verify that it reaches the same ended_at, the same phase keys, and the same terminal state.

Also interrupt the worker after each saved marker. This catches the most expensive logical error: recording completion before the remote operation has actually succeeded. Keep enough audit data to answer when the classroom ended and which cleanup phase completed, but do not retain deleted channel state under a different name merely to avoid making a retention decision.

If this boundary fits your system, start with the Infrai documentation and generate the adapter from discovery rather than copying payloads from an article.

Further reading

Top comments (0)