APIs & Devices NotesRohit’s interview study guide
Chapter 10

SDKs and Developer-Facing Interfaces

What turns a device API into something partners enjoy using: a small SDK in modern JavaScript with typed methods, sessions, timeouts, safe retries, a real error model and paging, then how to version, deprecate, release and document it so integrations survive years of firmware.

5 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What makes a good SDK

Added

An SDK (software development kit; for an HTTP API it's usually one client library per language) is your API repackaged in the idioms of the developer's language. Instead of learning that volume lives at PATCH /api/v1/settings, that the body must be JSON, that a session token goes in an Authorization header and that a 422 carries a list of field errors, a developer writes await device.updateSettings({ volume: 55 }) and catches a ValidationError. On a device platform the SDK's users are partner integrators (room-control systems, fleet-management tools, help-desk scripts), your own admin web UI team, QA automation, and the developers you lead. Every one of them would otherwise re-implement the same protocol details, slightly differently, with slightly different bugs.

A good mental model: an SDK is a phrasebook plus a fixer. The phrasebook part translates: URLs, headers, status codes and JSON become methods, typed objects and exceptions that read naturally in JavaScript. The fixer part handles the bureaucracy so callers don't have to: logging in, logging in again when the device reboots and forgets the session, waiting and retrying when the device says it's busy, following pagination cursors, attaching request ids to errors. A thin wrapper that only renames fetch is a phrasebook with no fixer: callers still need to know everything about the protocol, so it adds a dependency without removing work.

The qualities interviewers mean by "good SDK" fall into four groups:

QualityWhat it meansWhat it looks like in a device SDK
Ergonomicsthe common task is short and obviousdevice.updateSettings({ volume: 55 }); an options object last; sensible defaults for timeouts and retries
Error modelfailures are typed, actionable and safe to act onValidationError with the bad fields, RateLimitError with retryAfterSeconds, a request id on every error
Discoverabilitythe editor teaches the APItyped parameters and results, autocomplete for setting names, JSDoc on every method, examples that run
Stabilityupgrading doesn't break working codesemantic versioning, deprecate before removing, one major version supporting a wide range of firmware

Microsoft's Azure SDK General Guidelines (azure.github.io/azure-sdk, checked 27 October 2026) is the most detailed public write-up of these ideas and a good thing to name in an interview. It lists five principles: SDKs should be idiomatic ("feel natural to a developer in the target language"), consistent (within the language first, then with the service, then across languages), approachable (good docs, predictable defaults, core use cases easy to discover), diagnosable (developers can see what the SDK is doing and when network calls happen; error messages are concise and actionable), and dependable (breaking changes are avoided because they hurt users more than new features help).

Here's the difference in one task: set the speaker volume of a room device to 140, which is out of range. First with raw fetch, then through a twenty-line SDK. The device is a small Express app standing in for a real one (its API is this course's example, not any vendor's).

raw-vs-sdk.mjs
// A simulated device API (not a real product's), then the same task done twice:
// once with raw fetch, once through a small SDK. Task: set the speaker volume to 140.
import express from "express";

const app = express().use(express.json());
const tokens = new Set();
const settings = { roomName: "Huddle 2", volume: 40 };
const problem = (res, status, type, title, extra = {}) =>
  res.status(status).type("application/problem+json").json({ type: `https://device.example/problems/${type}`, title, status, ...extra });
app.post("/api/v1/session", (req, res) => {
  if (req.body.password !== "s3cret") return problem(res, 401, "bad-credentials", "Wrong username or password");
  tokens.add("tok-1");
  res.status(201).json({ token: "tok-1" });
});
app.patch("/api/v1/settings", (req, res) => {
  if (!tokens.has(req.get("authorization")?.slice(7))) return problem(res, 401, "unauthenticated", "Log in first");
  const { volume } = req.body;
  if (volume !== undefined && !(Number.isInteger(volume) && volume >= 0 && volume <= 100))
    return problem(res, 422, "validation-failed", "Invalid settings", { errors: [{ pointer: "#/volume", detail: "must be an integer from 0 to 100" }] });
  Object.assign(settings, req.body);
  res.json(settings);
});
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve)); // the port is known once it's listening
const base = `http://127.0.0.1:${server.address().port}`;

// 1. Raw fetch: every caller re-learns the protocol.
const login = await fetch(`${base}/api/v1/session`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ username: "admin", password: "s3cret" }) });
const { token } = await login.json();
const res = await fetch(`${base}/api/v1/settings`, { method: "PATCH", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ volume: 140 }) });
console.log("raw:", res.status, res.headers.get("content-type"), JSON.stringify(await res.json()));

// 2. Through an SDK: the protocol lives in one place.
class ValidationError extends Error {
  constructor(p) { super(`${p.title}: ${p.errors.map((e) => `${e.pointer} ${e.detail}`).join("; ")}`); this.name = "ValidationError"; this.fields = p.errors; }
}
class DeviceClient {
  #base; #creds; #token;
  constructor({ baseUrl, username, password }) { this.#base = baseUrl; this.#creds = { username, password }; }
  async #call(method, path, body) {
    this.#token ??= (await this.#raw("POST", "/api/v1/session", this.#creds)).token;
    return this.#raw(method, path, body, this.#token);
  }
  async #raw(method, path, body, token) {
    const res = await fetch(this.#base + path, { method, body: JSON.stringify(body),
      headers: { "content-type": "application/json", ...(token && { authorization: `Bearer ${token}` }) } });
    const data = await res.json();
    if (res.status === 422) throw new ValidationError(data);
    if (!res.ok) throw new Error(`${res.status} ${data.title}`);
    return data;
  }
  settings = { update: (patch) => this.#call("PATCH", "/api/v1/settings", patch) };
}

const device = new DeviceClient({ baseUrl: base, username: "admin", password: "s3cret" });
try {
  await device.settings.update({ volume: 140 });
} catch (err) {
  console.log("sdk:", err instanceof ValidationError, err.name, "-", err.message);
  console.log("sdk fields:", err.fields.map((f) => f.pointer).join(", "));
}
console.log("sdk ok:", JSON.stringify(await device.settings.update({ volume: 55 })));
server.close();
Result
raw: 422 application/problem+json; charset=utf-8 {"type":"https://device.example/problems/validation-failed","title":"Invalid settings","status":422,"errors":[{"pointer":"#/volume","detail":"must be an integer from 0 to 100"}]}
sdk: true ValidationError - Invalid settings: #/volume must be an integer from 0 to 100
sdk fields: #/volume
sdk ok: {"roomName":"Huddle 2","volume":55}
What’s happening
  1. The simulated device has two endpoints: POST /api/v1/session returns a token for the right password, and PATCH /api/v1/settings changes settings for a caller with a valid Authorization: Bearer token. A volume outside 0–100 gets a 422 with an RFC 9457 problem-details body (type, title, status) plus an errors array saying which field failed (see Errors with problem details).
  2. The raw caller has to know all of that: the login path and body shape, that the token comes back as token, the Bearer prefix, the content-type header on both requests, and that a failure still parses as JSON. What it gets back is a status (422) and a body to dig through. Every integrator writes this code again, and each one decides differently whether a 422 throws, logs or is silently ignored.
  3. DeviceClient hides the protocol behind one method, settings.update(patch). The private #call logs in lazily the first time (??= only runs the login when #token is still undefined), then sends the request with the token. Private # fields mean callers can't reach the token or the credentials by accident.
  4. #raw is where the protocol knowledge lives once: headers, JSON encoding and, crucially, the error mapping. A 422 becomes a ValidationError whose message is built from the problem's title and field errors, and whose fields property keeps the structured list. The caller writes catch (err) and checks err instanceof ValidationError; it never sees a status code unless it wants to.
  5. The output shows both sides: the raw call printed 422 application/problem+json; charset=utf-8 and the whole body; the SDK printed true ValidationError - Invalid settings: #/volume must be an integer from 0 to 100 and the field pointer #/volume, which a form can use to highlight the right input. The second update (55) succeeds and returns the new settings.
  6. This is the SDK's value in miniature: the protocol is written down once, in code that can be tested, versioned and documented, and every caller gets the same behaviour. The rest of this chapter grows #raw into a real request pipeline (re-login, timeouts, retries, paging) without changing the method callers use.
AdvancedDiagnosable: logging with secrets redacted

Diagnosable: an SDK that can say what it did

The Azure guidelines' "diagnosable" principle matters more on devices than anywhere else, because the question in a support ticket is always "what did your code actually send to the room?". A good SDK takes an optional logger, logs every exchange at debug level, redacts secrets, and attaches the device's request id. It also accepts an injected fetch, which is what lets this example run with a fake device and no network at all:

diagnosable.mjs
// "Diagnosable": an SDK that can say what it did. A logger option sees every HTTP exchange, with secrets redacted
// and the device's request id attached. The transport (fetch) is injectable, so this runs with a fake device.
const REDACT = new Set(["authorization", "cookie", "set-cookie", "x-api-key"]);
const redactHeaders = (h) => Object.fromEntries(Object.entries(h).map(([k, v]) => [k, REDACT.has(k.toLowerCase()) ? "REDACTED" : v]));
const redactBody = (b) => (b && "password" in b ? { ...b, password: "REDACTED" } : b);

function createClient({ baseUrl, fetch = globalThis.fetch, logger = null, clock = () => performance.now() }) {
  let token = null;
  async function send(method, path, body) {
    const headers = { "content-type": "application/json", ...(token && { authorization: `Bearer ${token}` }) };
    const started = clock();
    logger?.debug(`→ ${method} ${path} ${JSON.stringify(redactHeaders(headers))}${body ? " " + JSON.stringify(redactBody(body)) : ""}`);
    const res = await fetch(baseUrl + path, { method, headers, body: body && JSON.stringify(body) });
    logger?.debug(`← ${res.status} ${method} ${path} in ${Math.round(clock() - started)} ms request-id=${res.headers.get("x-request-id")}`);
    return res.json();
  }
  return {
    async login(username, password) { token = (await send("POST", "/api/v1/session", { username, password })).token; },
    getSettings: () => send("GET", "/api/v1/settings"),
  };
}

// A fake device behind a fake fetch, and a fake clock that advances 12 ms per reading.
let ids = 100;
const fakeFetch = async (url, init) => {
  const path = new URL(url).pathname;
  const body = path === "/api/v1/session" ? { token: "eyJhbGciOi.secret" } : { roomName: "Huddle 2", volume: 40 };
  return new Response(JSON.stringify(body), { status: path === "/api/v1/session" ? 201 : 200, headers: { "x-request-id": `r-${ids++}` } });
};
let now = 0;
const client = createClient({ baseUrl: "https://10.0.0.21", fetch: fakeFetch, logger: { debug: (line) => console.log(line) }, clock: () => (now += 12) });
await client.login("admin", "s3cret");
console.log("settings:", JSON.stringify(await client.getSettings()));
Result
→ POST /api/v1/session {"content-type":"application/json"} {"username":"admin","password":"REDACTED"}
← 201 POST /api/v1/session in 12 ms request-id=r-100
→ GET /api/v1/settings {"content-type":"application/json","authorization":"REDACTED"}
← 200 GET /api/v1/settings in 12 ms request-id=r-101
settings: {"roomName":"Huddle 2","volume":40}
What’s happening
  1. createClient takes its collaborators as options: fetch (defaulting to the global one), an optional logger and a clock. Nothing is hard-wired, so tests, browsers and Node can each pass what they have; this is also how the admin web UI would hand the SDK a fetch that sends cookies.
  2. Before each request, logger?.debug prints the method, path, headers and body, but redactHeaders replaces authorization, cookie, set-cookie and x-api-key with REDACTED, and redactBody hides a password field. The first line shows the login body as {"username":"admin","password":"REDACTED"}; the third shows the bearer token header as REDACTED.
  3. After the response, it logs the status, the duration and the device's x-request-id. The fake clock adds 12 ms per reading, so the durations are a stable 12 ms here; with the default performance.now() they'd be real. The request ids r-100 and r-101 are what a support engineer greps for in the device's own logs.
  4. fakeFetch returns real Response objects (a global in Node 18+), so the SDK code can't tell it from the network. The settings come back as {"roomName":"Huddle 2","volume":40}.
  5. If you removed the redaction, the first debug line would contain the admin password and the third the session token, and both would end up in a partner's log aggregator. Logs leave the building; secrets mustn't go with them.
AdvancedErgonomic conventions for SDK methods

Ergonomics: the conventions that make an SDK feel obvious

These are the small, repeatable decisions that make an SDK feel designed rather than accumulated. Several come straight from the Azure TypeScript guidelines (azure.github.io/azure-sdk/typescript_design.html, checked 27 October 2026); the rest are common practice.

  • A small set of verbs, used the same way everywhere. get, list, create, update, delete, plus begin… for long-running work such as beginFirmwareUpdate() (Azure: "prefix method names which return a poller with … begin"). Callers guess names correctly once they've seen two.
  • Required things are parameters, optional things go in an options object, last. updateSettings(patch, { abortSignal, timeoutInMs }). Adding an option later is a compatible change; adding a fourth positional parameter isn't pleasant for anyone.
  • Units in names. timeoutInMs, retryAfterSeconds (Azure: "suffix durations with In<Unit>"). A bare timeout: 5 has caused real outages.
  • The constructor does no I/O. new DeviceClient(options) only stores configuration; the first method call logs in. Constructors can't be awaited, and a client that connects in its constructor can't be created in tests or before the device is reachable.
  • Return plain data, not HTTP. Methods resolve to settings objects and arrays; the status code and headers are available on errors and, for the rare caller who needs them, through an escape hatch.
  • One client per device, safe to share. A fleet tool creates one DeviceClient per room and calls it from many places at once; the client must handle concurrent calls (see the single-flight login in the next topic).
AdvancedGenerated, handwritten or both
ApproachHowGood atWeak at
Generateda generator turns the OpenAPI document into a client (openapi-generator, or openapi-typescript types with its companion openapi-fetch)always matches the spec; cheap for many languagesnames and shapes mirror the HTTP API, not the developer's task; little "fixer" behaviour
Handwrittenengineers write the client by handidiomatic, task-shaped methods; good error modeldrifts from the spec unless contract-tested; costs time per language
Hybridgenerated types (and maybe a low-level client) underneath, a handwritten convenience layer on toptypes can't drift, methods still feel designedtwo layers to maintain and document

For a device platform with one main language (JavaScript or TypeScript for the web UI and partner scripts), the hybrid is usually the sweet spot: generate the types from the spec so they can't drift (see Generating types, clients and docs), and handwrite the session handling, retries, errors and paging, which a generator doesn't know about.

SDK or application?

Which of these belong inside a device SDK, and which in the application using it? (a) logging in again after the device reboots; (b) retrying POST /api/v1/calls (dial a call) when it times out; (c) showing a "device busy" toast; (d) following nextCursor through the call history; (e) caching settings for five minutes.

Show answer

SDK: (a) and (d). Application: (b), (c) and (e).

What’s happening
  1. (a) Re-login is pure protocol bureaucracy with exactly one right behaviour: if a call gets 401 because the session vanished, log in once with the stored credentials and repeat the call. Every caller would need the same code, so it belongs in the SDK.
  2. (d) Following cursors is also protocol detail. The SDK exposes an async iterator, and callers write for await (const call of device.listCalls()) without ever seeing a cursor.
  3. (b) Dialling isn't idempotent: if the request reached the device and only the response was lost, a blind retry places a second call. Only the application knows whether to check the active call first or ask the user, so the SDK reports a timeout that says "it may have been applied" and doesn't retry it.
  4. (c) The SDK raises a typed ServiceUnavailableError; what to show, in which language and where is a UI decision. An SDK that pops up UI or writes to console uninvited is a nuisance in a server process.
  5. (e) Caching is application policy: how stale settings may be depends on the screen. The SDK can make caching easy (return ETags, accept If-None-Match) without deciding the lifetime for everyone.

#Building a TypeScript client for a device API

Added

A device client has three layers, and keeping them apart is most of the design. The public surface is what developers touch: typed methods such as getSettings() and listCalls(), an options object, async iterators for lists. The request pipeline is what every call goes through, in a fixed order: attach the session (logging in if needed), set a deadline, retry what's safe to retry, turn failures into typed errors, log. The transport actually moves bytes, which in modern JavaScript is fetch (built into Node 18+ and every browser), injected so tests and the browser can supply their own.

The mental model is a post room. The methods are the forms customers fill in; they don't know or care how mail moves. The pipeline is the line of clerks every envelope passes: one stamps it with your credentials, one writes a deadline on it, one resends it if it's lost (but only if resending is harmless), one translates the reply into your language. The transport is the van. You can swap the van (a fake for tests, a cookie-sending fetch in the browser) without retraining a clerk, and add a clerk without changing a single form.

Your code partner integration · admin web UI · tests Public surface updateSettings(patch, { abortSignal }) Request pipeline (every call, in order) auth log in, re-login timeout per attempt retry only if safe errors problem → class logging redacted, request id Transport: fetch (injectable) Node · browser with cookies · fake for tests Device API over HTTPS · /api/v1 · problem+json who calls it what developers see types, docs, autocomplete what every call gets written once, tested once, same for every caller swap it, don't fork it no SDK change needed one device, many firmware versions
Three layers: the typed surface developers use, a fixed pipeline every call goes through, and an injectable transport.

Why are the runnable models below JavaScript if the topic says TypeScript? Because Node runs them as they are and the checker re-runs them on every build. They carry their types as JSDoc comments (@typedef, @param, @returns), which TypeScript reads too: the same code in a .ts file, or a .js file with // @ts-check, is type-checked by the compiler, and the published package ships .d.ts declarations so users get the same autocomplete either way. The TypeScript-only parts (the public types) are shown separately below and compiled with tsc in strict mode.

Here is the core of a device client: typed methods, lazy login that concurrent calls share, and exactly one re-login when the device forgets the session because it rebooted:

device-client.mjs
// A small device SDK: typed methods (JSDoc), lazy login shared by concurrent calls, and one re-login when the
// device forgets the session (it rebooted). The device is simulated with Express; it logs what it receives.
import express from "express";

const log = [];
const app = express().use(express.json());
let sessions = new Map(); // token -> username
let nextToken = 1;
app.use((req, _res, next) => { log.push(`${req.method} ${req.path} ${req.get("authorization") ?? "-"} ua=${req.get("user-agent")}`); next(); });
app.post("/api/v1/session", (req, res) => {
  const token = `t${nextToken++}`;
  sessions.set(token, req.body.username);
  setTimeout(() => res.status(201).json({ token }), 20); // logging in is slow on a device
});
app.use((req, res, next) => sessions.has(req.get("authorization")?.slice(7)) ? next()
  : res.status(401).type("application/problem+json").json({ type: "https://device.example/problems/unauthenticated", title: "Session expired", status: 401 }));
app.get("/api/v1/settings", (_req, res) => res.json({ roomName: "Huddle 2", volume: 40, mode: "teams" }));
app.get("/api/v1/status", (_req, res) => res.json({ inCall: false, uptimeSeconds: 912 }));
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve)); // the port is known once it's listening

/** @typedef {{ roomName: string, volume: number, mode: "teams" | "zoom" | "standard" }} DeviceSettings */
/** @typedef {{ inCall: boolean, uptimeSeconds: number }} DeviceStatus */
class DeviceClient {
  #baseUrl; #credentials; #token = null; #login = null;
  /** @param {{ baseUrl: string, username: string, password: string }} options */
  constructor({ baseUrl, username, password }) {
    this.#baseUrl = baseUrl.replace(/\/$/, "");
    this.#credentials = { username, password };
  }
  /** @returns {Promise<DeviceSettings>} */
  getSettings() { return this.#send("GET", "/api/v1/settings"); }
  /** @returns {Promise<DeviceStatus>} */
  getStatus() { return this.#send("GET", "/api/v1/status"); }

  async #send(method, path, body) {
    const token = await this.#ensureToken();
    const res = await this.#fetch(method, path, body, token);
    if (res.status !== 401) return res.json();
    if (this.#token === token) this.#token = null; // the device forgot us (reboot, timeout): log in again, once
    const retry = await this.#fetch(method, path, body, await this.#ensureToken());
    if (retry.status === 401) throw new Error("Device rejected the credentials");
    return retry.json();
  }
  #ensureToken() {
    if (this.#token) return this.#token;
    // Single flight: every caller waiting for a login shares the same promise.
    this.#login ??= this.#fetch("POST", "/api/v1/session", this.#credentials)
      .then(async (r) => {
        if (!r.ok) throw new Error(`Login failed: HTTP ${r.status}`);
        return (this.#token = (await r.json()).token);
      })
      .finally(() => (this.#login = null));
    return this.#login;
  }
  #fetch(method, path, body, token) {
    return fetch(this.#baseUrl + path, {
      method,
      headers: { "user-agent": "device-sdk-js/1.4.0", "content-type": "application/json", ...(token && { authorization: `Bearer ${token}` }) },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
  }
}

const device = new DeviceClient({ baseUrl: `http://127.0.0.1:${server.address().port}/`, username: "admin", password: "s3cret" });
const [settings, status] = await Promise.all([device.getSettings(), device.getStatus()]);
console.log("settings:", settings.roomName, settings.volume, settings.mode, "| status: inCall", status.inCall);
console.log("device saw (sorted, the calls ran in parallel):");
for (const line of log.splice(0).sort()) console.log("  " + line);
sessions = new Map(); // the device reboots and forgets every session
console.log("after reboot:", (await device.getSettings()).roomName);
console.log("device saw (in order):");
for (const line of log) console.log("  " + line);
server.close();
Result
settings: Huddle 2 40 teams | status: inCall false
device saw (sorted, the calls ran in parallel):
  GET /api/v1/settings Bearer t1 ua=device-sdk-js/1.4.0
  GET /api/v1/status Bearer t1 ua=device-sdk-js/1.4.0
  POST /api/v1/session - ua=device-sdk-js/1.4.0
after reboot: Huddle 2
device saw (in order):
  GET /api/v1/settings Bearer t1 ua=device-sdk-js/1.4.0
  POST /api/v1/session - ua=device-sdk-js/1.4.0
  GET /api/v1/settings Bearer t2 ua=device-sdk-js/1.4.0
What’s happening
  1. The simulated device logs every request it receives (method, path, Authorization header, User-Agent). Login takes 20 ms, like a slow device, and gives out tokens t1, t2 and so on. Any other path without a known token gets a 401 problem. sessions is a let, so the script can replace it with an empty Map later to simulate a reboot.
  2. DeviceClient stores the base URL (with any trailing / stripped, a classic double-slash bug) and the credentials, and does no I/O in the constructor. The JSDoc @typedef lines declare DeviceSettings and DeviceStatus, so editors show settings.mode as "teams" | "zoom" | "standard", and getSettings() and getStatus() say what they return.
  3. Promise.all starts getSettings() and getStatus() at the same time. Both call #ensureToken() while #token is still null. The first creates the login promise and stores it in #login; the second finds #login already set (??= doesn't run its right side) and awaits the same promise. That's a single-flight login: the sorted log shows one POST /api/v1/session and both GETs using Bearer t1. Without it, a dashboard that loads ten panels at once would open ten sessions on a device that may only allow a handful.
  4. .finally(() => (this.#login = null)) clears the shared promise once it settles, so a later re-login can start a fresh one. If login fails (!r.ok), the promise rejects and every waiting caller gets the same error instead of a token of undefined.
  5. The script empties sessions: the device rebooted. The next getSettings() sends Bearer t1 and gets 401. #send then clears the token, but only if it's still the one it used (this.#token === token), logs in again (t2) and repeats the request once. The in-order log shows exactly that: the rejected GET with t1, one POST /api/v1/session, the repeated GET with t2. A second 401 means the credentials themselves are wrong, so it throws instead of looping.
  6. Every request carries user-agent: device-sdk-js/1.4.0. Device logs and support teams can then tell SDK traffic (and which SDK version) from browsers and scripts; it's how you find out how many partners still run an old release. (In a browser, the SDK should send a custom header such as x-sdk-version instead, because the browser controls its own user agent.)
AdvancedTypes from the OpenAPI document

The types in that client were written by hand. In a real SDK they should come from the API's OpenAPI document, so they can't drift from what the device implements. openapi-typescript (7.13 here) turns a spec into TypeScript types; it has a programmatic API, so this model runs it on a small spec and prints the schema part of the output:

gen-types.mjs
// Generate TypeScript types for the SDK from the device's OpenAPI document with openapi-typescript 7.
import openapiTS, { astToString } from "openapi-typescript";

const json = (schema) => ({ content: { "application/json": { schema } } });
const spec = {
  openapi: "3.1.0",
  info: { title: "Room device API", version: "1.3.0" },
  paths: {
    "/api/v1/settings": {
      get: { operationId: "getSettings", responses: { 200: { description: "Current settings", ...json({ $ref: "#/components/schemas/DeviceSettings" }) } } },
      patch: {
        operationId: "updateSettings",
        requestBody: { required: true, content: { "application/merge-patch+json": { schema: { $ref: "#/components/schemas/SettingsPatch" } } } },
        responses: { 200: { description: "Updated settings", ...json({ $ref: "#/components/schemas/DeviceSettings" }) } },
      },
    },
  },
  components: {
    schemas: {
      DeviceSettings: {
        type: "object",
        required: ["roomName", "volume", "mode"],
        properties: {
          roomName: { type: "string", maxLength: 64 },
          volume: { type: "integer", minimum: 0, maximum: 100, description: "Speaker volume in percent" },
          mode: { type: "string", enum: ["teams", "zoom", "standard"] },
        },
      },
      SettingsPatch: {
        type: "object",
        additionalProperties: false,
        properties: { roomName: { type: "string" }, volume: { type: "integer" }, mode: { $ref: "#/components/schemas/DeviceSettings/properties/mode" } },
      },
    },
  },
};

const source = astToString(await openapiTS(spec));
// Print only the schemas part of the generated file (the full file also has paths and operations).
const start = source.indexOf("export interface components");
console.log(source.slice(start, source.indexOf("    responses: never;", start)).trimEnd());
console.log("...");
console.log("operations:", [...source.matchAll(/^    (\w+): \{$/gm)].map((m) => m[1]).filter((n) => /Settings$/.test(n)).join(", "));
Result
export interface components {
    schemas: {
        DeviceSettings: {
            roomName: string;
            /** @description Speaker volume in percent */
            volume: number;
            /** @enum {string} */
            mode: "teams" | "zoom" | "standard";
        };
        SettingsPatch: {
            roomName?: string;
            volume?: number;
            mode?: components["schemas"]["DeviceSettings"]["mode"];
        };
    };
...
operations: getSettings, updateSettings
What’s happening
  1. The spec is an OpenAPI 3.1 object with two operations on /api/v1/settings, getSettings and updateSettings (the operationIds become names in the generated operations interface), and two schemas: DeviceSettings with three required properties, and SettingsPatch where everything is optional, as a JSON Merge Patch body should be.
  2. openapiTS(spec) returns a TypeScript syntax tree, and astToString prints it as source. The script slices out the components interface (the generated file also has paths, operations and some empty sections) and lists the operation names it found: getSettings, updateSettings.
  3. Look at what survived: required became non-optional properties, the enum became the union "teams" | "zoom" | "standard", the description became a doc comment that editors show on hover, and the $ref in SettingsPatch became a reference to the same union, so the two can't disagree.
  4. Look at what didn't: minimum: 0, maximum: 100 and maxLength: 64 are gone, because TypeScript types can't express ranges, and integer became plain number. Types catch wrong names and wrong shapes at compile time; they don't validate values. The device still validates and answers 422, which is why the SDK needs a ValidationError even with perfect types.
  5. In a real repository this runs as a build step (npx openapi-typescript openapi.yaml -o src/generated/api.d.ts), and CI fails if the generated file changes without a matching spec change; the spec-first workflow is covered in Generating types, clients and docs.
AdvancedThe public surface in TypeScript

The SDK then exposes friendlier names over the generated ones. This block is the public surface of the client (as an interface, so it compiles on its own), followed by the mistakes it catches; it's compiled with TypeScript in strict mode, and every ❌ line is a real compiler error:

device-sdk.ts
// Generated by openapi-typescript (the excerpt printed above, shortened)
interface components {
  schemas: {
    DeviceSettings: { roomName: string; volume: number; mode: "teams" | "zoom" | "standard" };
    SettingsPatch: { roomName?: string; volume?: number; mode?: components["schemas"]["DeviceSettings"]["mode"] };
  };
}

// The SDK's public names: friendly aliases over the generated ones.
export type DeviceSettings = components["schemas"]["DeviceSettings"];
export type SettingsPatch = components["schemas"]["SettingsPatch"];

export interface OperationOptions {
  /** Cancel the call. The operation on the device may still complete. */
  abortSignal?: AbortSignal;
  /** Per-attempt timeout; defaults to 10 000 ms. */
  timeoutInMs?: number;
}

export interface DeviceClient {
  getSettings(options?: OperationOptions): Promise<DeviceSettings>;
  updateSettings(patch: SettingsPatch, options?: OperationOptions): Promise<DeviceSettings>;
  getSetting<K extends keyof DeviceSettings>(name: K, options?: OperationOptions): Promise<DeviceSettings[K]>;
}

declare const device: DeviceClient;

const mode = await device.getSetting("mode"); // mode: "teams" | "zoom" | "standard"
const volume: number = await device.getSetting("volume");
await device.updateSettings({ volume: 55 });
await device.updateSettings({ volum: 55 }); // ❌ TS2561 Did you mean to write 'volume'?
await device.updateSettings({ mode: "webex" }); // ❌ TS2322 not assignable
await device.getSetting("brightness"); // ❌ TS2345 not assignable to keyof DeviceSettings
await device.getSettings({ timeout: 5000 }); // ❌ TS2353 'timeout' does not exist; it's timeoutInMs
What’s happening
  1. DeviceSettings and SettingsPatch are aliases of the generated types, so a spec change flows straight into the SDK's public types on the next build. Exporting friendly names means users never import from a generated/ path, so you can regenerate or restructure freely.
  2. getSetting<K extends keyof DeviceSettings>(name: K) returns Promise<DeviceSettings[K]>: the compiler looks up the type of the named property. getSetting("mode") therefore resolves to the union "teams" | "zoom" | "standard", and getSetting("volume") to number, which is why assigning it to volume: number compiles.
  3. updateSettings({ volum: 55 }) fails with the compiler's real message: "TS2561 Object literal may only specify known properties, but 'volum' does not exist in type '{ roomName?: string | undefined; volume?: number | undefined; mode?: "standard" | "teams" | "zoom" | undefined; }'. Did you mean to write 'volume'?". At run time the device would have ignored or rejected the unknown key; here the editor underlines it as you type.
  4. mode: "webex" gets "TS2322 Type '"webex"' is not assignable to type '"standard" | "teams" | "zoom" | undefined'", and getSetting("brightness") gets "TS2345 Argument of type '"brightness"' is not assignable to parameter of type '"mode" | "roomName" | "volume"'". The list of valid values is now discoverable: autocomplete offers exactly those three names.
  5. getSettings({ timeout: 5000 }) gets "TS2353 Object literal may only specify known properties, and 'timeout' does not exist in type 'OperationOptions'". This is the units convention paying off: the option is timeoutInMs, and the compiler stops someone passing seconds to a field they guessed the name of.
AdvancedPagination as async iterators

The device pages its call history with a cursor (?limit=…&cursor=…, nextCursor in the response). The SDK's job is to make callers forget that: for await over every item, with pages fetched lazily, plus a byPage() view for callers who want pages and a resumable position. This is the shape the Azure TypeScript guidelines prescribe (a list method returning an iterator with byPage() that takes a continuationToken):

paging.mjs
// Pagination in an SDK: callers write `for await`, the SDK follows the device's cursors, one page at a time.
import express from "express";

const calls = Array.from({ length: 7 }, (_, i) => ({ id: `c${i + 1}`, remote: `room-${i + 1}@example.com` }));
const requests = [];
const app = express();
app.get("/api/v1/calls", (req, res) => {
  requests.push(req.url);
  const limit = Math.min(Number(req.query.limit ?? 50), 50);
  const start = req.query.cursor ? calls.findIndex((c) => c.id === req.query.cursor) + 1 : 0; // cursor = last id seen
  const items = calls.slice(start, start + limit);
  const more = start + limit < calls.length;
  res.json({ items, nextCursor: more ? items.at(-1).id : null });
});
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve)); // the port is known once it's listening
const baseUrl = `http://127.0.0.1:${server.address().port}`;

/**
 * @typedef {{ id: string, remote: string }} CallRecord
 * @param {{ pageSize?: number }} [options]
 */
function listCalls({ pageSize = 50 } = {}) {
  /** @param {string | null} [continuationToken] @returns {AsyncGenerator<CallRecord[]>} */
  async function* byPage(continuationToken = null) {
    do {
      const query = new URLSearchParams({ limit: String(pageSize), ...(continuationToken && { cursor: continuationToken }) });
      const page = await (await fetch(`${baseUrl}/api/v1/calls?${query}`)).json();
      continuationToken = page.nextCursor;
      yield Object.assign(page.items, { continuationToken }); // a resumable position travels with the page
    } while (continuationToken);
  }
  return {
    async *[Symbol.asyncIterator]() { for await (const page of byPage()) yield* page; },
    byPage,
  };
}

const ids = [];
for await (const call of listCalls({ pageSize: 3 })) ids.push(call.id);
console.log("all:", ids.join(" "), "| requests:", requests.splice(0).join(" "));

for await (const call of listCalls({ pageSize: 3 })) {
  if (call.id === "c2") break; // stop early: the generator is closed, no more pages are fetched
}
console.log("break at c2 | requests:", requests.splice(0).join(" "));

const pages = listCalls({ pageSize: 3 }).byPage();
const first = (await pages.next()).value;
console.log("page 1:", first.map((c) => c.id).join(" "), "| resume token:", first.continuationToken);
for await (const page of listCalls({ pageSize: 3 }).byPage(first.continuationToken)) console.log("resumed page:", page.map((c) => c.id).join(" "));
console.log("requests:", requests.join(" "));
server.close();
Result
all: c1 c2 c3 c4 c5 c6 c7 | requests: /api/v1/calls?limit=3 /api/v1/calls?limit=3&cursor=c3 /api/v1/calls?limit=3&cursor=c6
break at c2 | requests: /api/v1/calls?limit=3
page 1: c1 c2 c3 | resume token: c3
resumed page: c4 c5 c6
resumed page: c7
requests: /api/v1/calls?limit=3 /api/v1/calls?limit=3&cursor=c3 /api/v1/calls?limit=3&cursor=c6
What’s happening
  1. The device holds seven call records and returns at most limit per request, starting after the record whose id is the cursor. nextCursor is the last id on the page, or null on the last page. (A real API would make the cursor an opaque string, such as base64 of the sort key, so clients can't build or depend on it.)
  2. listCalls() returns an object with two faces. byPage(continuationToken) is an async generator that fetches one page, updates the token from nextCursor, yields the page (with the token attached, so a caller can save its position) and loops while there's a next page. [Symbol.asyncIterator] makes the object itself usable in for await, flattening pages into items with yield*.
  3. The first loop collects every id, c1 to c7, and the device saw three requests: no cursor, cursor=c3, cursor=c6. The caller never saw a cursor.
  4. The second loop breaks at c2. Breaking out of for await calls the iterator's return(), which closes both generators, so the do … while never runs again: the device saw one request. Lazy paging matters on a device: listing "the most recent five calls" mustn't download the whole history.
  5. The third part calls byPage() directly, takes page 1 (c1 c2 c3) and its token c3, then starts a new byPage("c3"), which resumes at c4 c5 c6 and then c7. That's how a UI implements "load more" or a sync job resumes after a restart: it stores the token, not a page number.
  6. Notice there's no toArray(). Azure's guidelines say not to offer one ("DO NOT expose an API to get a paginated collection into an array"), because it hides an unbounded download behind an innocent-looking call. A caller who really wants everything writes the loop, and sees what it costs.
AdvancedAuthentication choices for a device SDK

The model uses the simplest device pattern: log in with a username and password, get a session token, send it on every call. Device APIs differ, so the SDK's constructor should take a credential object rather than hard-coding one scheme:

CredentialHow the SDK uses itTypical on devicesNotes
Username + password → sessionlog in lazily, re-login once on 401admin REST APIs on the device itselfthe SDK must hold the password in memory to re-login; offer a callback that fetches it instead
API key or tokensend it on every request; no logincloud management services, integration accountsrotate without restarting: accept a function that returns the current key
Browser session cookiefetch(…, { credentials: "include" }), no credentials in the SDK at allthe device's own admin web UICSRF protection applies; see Securing the on-device web UI
HTTP Basic or Digestheader on every requestolder device firmwareonly over TLS; see Authenticating admins and integrations

Poly's public Poly VideoOS REST API Reference Guide (HP document P55757-001A, "Last update: August 2025", for VideoOS 4.4.0) describes the same session-based shape: "Administrator credentials are required for access authentication before REST API commands can be sent", and "You need a session ID using /rest/session/ to pass API commands to the system." Its POST /rest/session takes { "user", "password" } and returns a session object with a sessionId and a role, or 403 LOG-IN ATTEMPT FAILED. The guide doesn't say how the session is presented on later requests (cookie or header), so an SDK author has to take that from another source; Poly video devices and PolyOS in brief covers what HP's other public material shows. The pattern is the one modelled above.

Why compare before clearing the token?

In the client above, #send does if (this.#token === token) this.#token = null before re-logging in. Suppose it simply did this.#token = null. The device reboots while two calls are in flight with t1: call A's 401 arrives immediately, call B's arrives 100 ms later. What happens?

Show answer

Call B throws away the fresh session A just created and logs in a second time: three logins in total instead of two.

What’s happening
  1. A gets 401, clears the token and starts a re-login. Login takes 20 ms, so after about 20 ms #token is t2, A repeats its request with t2, and it succeeds.
  2. B's request was sent earlier with t1. Its 401 arrives at about 100 ms, after A's re-login finished. B clears #token, which now holds t2, a perfectly good session.
  3. B's #ensureToken() finds no token and no login in progress (A's #login was reset by finally), so it starts another login: t3. On a device with a small session limit, that's one more session used, and in a busier dashboard it repeats for every late 401.
  4. With the comparison, B sees this.#token is t2, not the t1 it used, so it leaves it alone, and its retry simply uses t2. Run against the same simulated device with a 100 ms delay on B's 401, the unconditional version logs in three times in total and the compare version twice.
  5. The general lesson: in concurrent code, "the state I saw" and "the state now" can differ across an await. Decide based on what you saw, then change the shared state only if it hasn't moved.

#Errors, retries and timeouts in an SDK

Added

When a call fails, the caller needs three answers, fast: what happened, whose problem is it (my input, the device's current state, or the device or network itself), and is it safe to try again. A good SDK answers all three in the error it throws, so application code can be a short catch instead of a pile of status-code checks. The error classes, their names and their properties are part of the SDK's public API, as much as its methods are: partners write catch (err) { if (err instanceof ConflictError) … } and you can't rename that later.

The mental model is a triage nurse. Every failure arrives at the same desk and is sorted: "you filled in the form wrong" (validation, don't retry, fix the input), "not right now" (the device is in a call or updating; retry later or tell the user), "the line went dead" (network, timeout, 502/503/504; retry if repeating is harmless), "you're not allowed" (auth; re-login once or give up). The nurse also checks one thing before sending anyone back into the queue: would doing it twice hurt? Reading status twice is harmless; dialling a call twice is not.

The device tells you what happened through RFC 9457 problem details (the type URI, title, status, detail; see Errors with problem details). The SDK maps the type first, because it's the stable, specific code partners can rely on, and falls back to the status when the body isn't a problem at all (a proxy's HTML error page, a crash):

StatusProblem type (this API)SDK classRetry automatically?
401unauthenticatedAuthenticationErrorno; the client re-logs in once itself
403forbiddenPermissionErrorno; needs a different account or role
404not-foundNotFoundErrorno
409 / 412in-call, etag-mismatchConflictErrorno; the state must change first (or re-read and re-apply)
422validation-failedValidationErrorno; fix the input (fields says where)
429rate-limitedRateLimitErroryes, after Retry-After
503updating, busyServiceUnavailableErroryes, with backoff, if the call is idempotent
500 / 502 / 504, network errornone (often not JSON)DeviceApiError, ConnectionErroryes, with backoff, if the call is idempotent

Here's the mapping as code, with the simulated device answering eight paths with eight different failures:

errors.mjs
// A typed error hierarchy mapped from RFC 9457 problem details. The simulated device answers each path with a
// different failure; the SDK turns each one into an error class callers can catch with instanceof.
import express from "express";

const P = "https://device.example/problems/";
const failures = {
  "/a": [401, { type: P + "unauthenticated", title: "Session expired" }],
  "/b": [403, { type: P + "forbidden", title: "Role 'viewer' cannot change settings" }],
  "/c": [404, { type: P + "not-found", title: "No camera with id 'cam9'" }],
  "/d": [409, { type: P + "in-call", title: "Cannot reboot during a call", detail: "End call c41 first" }],
  "/e": [422, { type: P + "validation-failed", title: "Invalid settings", errors: [{ pointer: "#/volume", detail: "must be 0 to 100" }] }],
  "/f": [429, { type: P + "rate-limited", title: "Too many requests" }],
  "/g": [503, { type: P + "updating", title: "Firmware update in progress" }],
  "/h": [500, null], // a proxy or a crash: no problem details at all
};
const app = express();
app.get("/:k", (req, res) => {
  const [status, body] = failures["/" + req.params.k];
  res.set("x-request-id", `req-${req.params.k}`);
  if (status === 429) res.set("retry-after", "2");
  body ? res.status(status).type("application/problem+json").json({ status, ...body }) : res.status(status).type("text/html").send("<h1>Internal Server Error</h1>");
});
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve)); // the port is known once it's listening

class DeviceApiError extends Error {
  retryable = false;
  constructor(problem, { status, requestId }) {
    super(problem.detail ? `${problem.title}: ${problem.detail}` : problem.title);
    Object.assign(this, { name: new.target.name, status, type: problem.type, problem, requestId });
  }
}
class AuthenticationError extends DeviceApiError {}
class PermissionError extends DeviceApiError {}
class NotFoundError extends DeviceApiError {}
class ConflictError extends DeviceApiError {}
class ValidationError extends DeviceApiError { get fields() { return this.problem.errors ?? []; } }
class RateLimitError extends DeviceApiError { retryable = true; }
class ServiceUnavailableError extends DeviceApiError { retryable = true; }

const byType = { unauthenticated: AuthenticationError, forbidden: PermissionError, "not-found": NotFoundError,
  "in-call": ConflictError, "validation-failed": ValidationError, "rate-limited": RateLimitError, updating: ServiceUnavailableError };
const byStatus = { 401: AuthenticationError, 403: PermissionError, 404: NotFoundError, 409: ConflictError, 412: ConflictError,
  422: ValidationError, 429: RateLimitError, 503: ServiceUnavailableError };

async function toError(res) {
  const isProblem = res.headers.get("content-type")?.startsWith("application/problem+json");
  const problem = isProblem ? await res.json() : { title: `HTTP ${res.status} ${res.statusText}` };
  const Class = byType[problem.type?.replace(P, "")] ?? byStatus[res.status] ?? DeviceApiError;
  const err = new Class(problem, { status: res.status, requestId: res.headers.get("x-request-id") });
  if (err instanceof RateLimitError) err.retryAfterSeconds = Number(res.headers.get("retry-after"));
  if (res.status >= 500 && Class === DeviceApiError) err.retryable = true; // unknown server failure
  return err;
}

for (const path of Object.keys(failures)) {
  const res = await fetch(`http://127.0.0.1:${server.address().port}${path}`);
  const err = await toError(res);
  const extra = err instanceof ValidationError ? ` fields=${err.fields.map((f) => f.pointer)}` : err.retryAfterSeconds ? ` retryAfter=${err.retryAfterSeconds}s` : "";
  console.log(`${err.status} ${err.name.padEnd(23)} retryable=${String(err.retryable).padEnd(5)} ${err.requestId} "${err.message}"${extra}`);
}
server.close();
Result
401 AuthenticationError     retryable=false req-a "Session expired"
403 PermissionError         retryable=false req-b "Role 'viewer' cannot change settings"
404 NotFoundError           retryable=false req-c "No camera with id 'cam9'"
409 ConflictError           retryable=false req-d "Cannot reboot during a call: End call c41 first"
422 ValidationError         retryable=false req-e "Invalid settings" fields=#/volume
429 RateLimitError          retryable=true  req-f "Too many requests" retryAfter=2s
503 ServiceUnavailableError retryable=true  req-g "Firmware update in progress"
500 DeviceApiError          retryable=true  req-h "HTTP 500 Internal Server Error"
What’s happening
  1. failures lists what the device returns per path: seven problem-details bodies, each with a type under https://device.example/problems/, and one plain HTML 500 with no problem body, like a crashed process or a proxy error page. Every response carries an x-request-id, and the 429 also has Retry-After: 2.
  2. DeviceApiError is the base class: it builds a message from title and detail ("Cannot reboot during a call: End call c41 first"), and keeps status, type, the whole problem and the requestId. name: new.target.name sets err.name to the subclass actually constructed, so logs say ConflictError, not Error. Each subclass adds only what's specific: ValidationError exposes fields; RateLimitError and ServiceUnavailableError set retryable = true.
  3. toError reads the body as JSON only if the content type is application/problem+json; otherwise it makes a minimal problem from the status line ("HTTP 500 Internal Server Error"). It picks the class by type first (byType), then by status (byStatus), then falls back to the base class. Mapping by type lets the device add a new type such as in-call under an existing status without the SDK misreading it.
  4. The output lines show each mapping: 401 → AuthenticationError, 403 → PermissionError, through 503 → ServiceUnavailableError. ValidationError lists fields=#/volume, and RateLimitError carries retryAfter=2s from the header. The unknown 500 became a base DeviceApiError marked retryable=true, because server-side failures are usually transient.
  5. Note what retryable means: the failure is transient, not "retry this call". Whether the SDK may actually resend depends on the call too: a 503 on GET /status is retried, a 503 on POST /calls is not, because the device might have placed the call before failing. The next model makes that decision.
  6. Every line keeps the device's requestId (req-a … req-h). Put it in every error message you log or show in an admin UI's "details" panel; it's the one value that ties a partner's bug report to the device's logs.
AdvancedSafe retries with backoff and jitter

Retries: exponential backoff with jitter, only when repeating is safe

The retry rules most SDKs converge on: retry transient failures (network errors, timeouts, 408, 429, 500, 502, 503, 504), only for calls that are safe to repeat (GET, HEAD, OPTIONS, PUT, DELETE, or a POST that carries an Idempotency-Key the device honours), a small number of times, with a growing, randomised wait between attempts, and honouring Retry-After when the device sends it. Public guidance agrees on the shape:

  • Google's AIP-194 (Automatic retry configuration, approved 2019) says clients "should not automatically retry requests in which repeated runs would cause unintended state changes".
  • The Azure Core guidelines (azure.github.io/azure-sdk/general_azurecore.html, checked 27 October 2026) recommend by default 3 retries with a 0.8 s exponential delay plus jitter and at most 60 s between attempts, keeping the same client request id across retries, and letting users turn retries off.
  • AWS's architecture blog post "Exponential Backoff And Jitter" (Marc Brooker, March 2015, updated May 2023) compared jitter strategies and named the "Full Jitter" approach used here: wait a random time between zero and the capped exponential delay.
An attempt failed Did the caller cancel? yes Stop: rethrow the caller's reason no Transient failure? network, timeout, 408, 429, 5xx no Throw the typed error (ValidationError…) yes Safe to repeat? GET/PUT/DELETE or Idempotency-Key no Throw: "may have been applied" yes Attempts left? no Throw the last error yes Wait, then send again Retry-After, else random(0, capped 2ⁿ·base)
Four questions, in this order, decide whether an SDK resends a failed request.

This model implements those rules against a device that fails in four different ways. Sleeping is faked (the delays are recorded, not waited) and the jitter comes from a seeded PRNG, so the output is the same every run:

retry.mjs
// Retries with exponential backoff and full jitter, only for calls that are safe to repeat.
// The device is simulated; sleeping is faked (delays are recorded, not waited) and the jitter PRNG is seeded.
import express from "express";

const app = express().use(express.json());
const seen = {};
const count = (k) => (seen[k] = (seen[k] ?? 0) + 1);
const reboots = new Map(); // Idempotency-Key -> stored response
app.get("/api/v1/status", (_req, res) => (count("status") <= 2 ? res.sendStatus(503) : res.json({ inCall: false })));
app.put("/api/v1/settings/volume", (req, res) => (count("volume") === 1 ? res.set("retry-after", "3").sendStatus(429) : res.json({ volume: req.body.value })));
app.post("/api/v1/calls", (_req, res) => { count("dial"); res.sendStatus(503); });
app.post("/api/v1/reboot", (req, res) => {
  const key = req.get("idempotency-key");
  if (!reboots.has(key)) { count("reboots"); reboots.set(key, { operationId: "op-7" }); return res.sendStatus(502); } // done, but the reply is lost
  res.status(202).json(reboots.get(key));
});
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve)); // the port is known once it's listening
const base = `http://127.0.0.1:${server.address().port}`;

function mulberry32(a) { return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
const random = mulberry32(42);
const slept = [];
const sleep = async (ms) => slept.push(ms); // fake clock

const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
const IDEMPOTENT = new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]);
const policy = { maxRetries: 3, baseDelayMs: 500, maxDelayMs: 8000 };

async function send(method, path, { body, idempotencyKey } = {}) {
  const canRetry = IDEMPOTENT.has(method) || idempotencyKey !== undefined;
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(base + path, { method, body: body && JSON.stringify(body),
      headers: { "content-type": "application/json", ...(idempotencyKey && { "idempotency-key": idempotencyKey }) } });
    if (res.ok) return res.json();
    if (!canRetry || !RETRYABLE_STATUS.has(res.status) || attempt === policy.maxRetries)
      throw new Error(`HTTP ${res.status} after ${attempt + 1} attempt(s)${canRetry ? "" : " (not retried: not idempotent)"}`);
    const retryAfter = Number(res.headers.get("retry-after")) * 1000; // seconds form only, in this model
    const backoff = Math.round(random() * Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** attempt)); // full jitter
    await sleep(retryAfter || backoff);
  }
}

const report = async (label, promise) => {
  slept.length = 0;
  const outcome = await promise.then((v) => `ok ${JSON.stringify(v)}`, (e) => `error: ${e.message}`);
  console.log(`${label.padEnd(30)} ${outcome}  waits=[${slept.join(", ")}]`);
};
await report("GET status (503, 503, ok)", send("GET", "/api/v1/status"));
await report("PUT volume (429 + Retry-After)", send("PUT", "/api/v1/settings/volume", { body: { value: 55 } }));
await report("POST dial (503)", send("POST", "/api/v1/calls", { body: { to: "room-4@example.com" } }));
await report("POST reboot + Idempotency-Key", send("POST", "/api/v1/reboot", { idempotencyKey: "9f1c" }));
console.log("device counted:", JSON.stringify(seen));
server.close();
Result
GET status (503, 503, ok)      ok {"inCall":false}  waits=[301, 448]
PUT volume (429 + Retry-After) ok {"volume":55}  waits=[3000]
POST dial (503)                error: HTTP 503 after 1 attempt(s) (not retried: not idempotent)  waits=[]
POST reboot + Idempotency-Key  ok {"operationId":"op-7"}  waits=[335]
device counted: {"status":3,"volume":2,"dial":1,"reboots":1}
What’s happening
  1. The device: GET /status fails twice with 503 then succeeds; PUT /settings/volume answers the first try with 429 and Retry-After: 3; POST /calls (dial) always fails with 503; POST /reboot performs the reboot and then "loses" the reply with a 502, but remembers the result under the request's Idempotency-Key, so a repeat with the same key returns the stored 202 without rebooting again.
  2. send decides once whether the call may be retried at all: canRetry is true for idempotent methods or when an idempotency key is supplied. Inside the loop it returns on success and throws when the call can't be retried, the status isn't transient, or maxRetries (3) is used up.
  3. The wait is Retry-After if the device sent one, otherwise full jitter: random() × min(maxDelayMs, baseDelayMs × 2^attempt). For GET status the ceilings are 500 ms then 1 000 ms, and the seeded draws gave 301 and 448. Randomising matters when a whole fleet of clients failed at the same moment (the device rebooted, a switch flapped): without jitter they'd all retry in lockstep and hit the device together again.
  4. PUT volume waited exactly 3000 ms because the device said Retry-After: 3; the device knows its own recovery better than a formula does. (A full SDK also accepts the HTTP-date form of Retry-After and caps it, say at 60 s like Azure's guidance, so a buggy header can't stall a caller for an hour.)
  5. POST dial failed after one attempt with (not retried: not idempotent), and waits=[]. The 503 might have come after the device started dialling; a blind retry could put a room into two calls. POST reboot was retried, once, because it carried Idempotency-Key: 9f1c: the first attempt rebooted and lost its reply, the retry got the stored {"operationId":"op-7"}.
  6. The device's counters prove it: status handled 3 requests, volume 2, dial 1, and reboots 1 even though the client sent two reboot requests. That's the whole point of idempotency keys: retries become safe for operations that aren't naturally idempotent.
AdvancedTimeouts and cancellation with AbortSignal

A call without a timeout can hang forever when a device drops off the network mid-response, and a dashboard with forty hung calls is a frozen dashboard. JavaScript's standard tool for both timeouts and cancellation is AbortSignal: AbortSignal.timeout(ms) (Node 17.3+ / 16.14+) aborts itself after a delay with a TimeoutError, AbortSignal.any([a, b]) (Node 20.3+ / 18.17+) aborts when any of its inputs does, and fetch takes a signal. The Azure TypeScript guidelines ask SDKs to accept an abort signal "on all asynchronous calls", named abortSignal.

timeouts.mjs
// Timeouts and cancellation with AbortSignal: a per-attempt timeout (AbortSignal.timeout) combined with the
// caller's own signal (AbortSignal.any). Timeouts of idempotent calls are retried; the caller's cancel never is.
import express from "express";

const hits = {};
const app = express();
const slowFirst = (name) => (_req, res) => {
  hits[name] = (hits[name] ?? 0) + 1;
  setTimeout(() => res.json({ ok: true, attempt: hits[name] }), hits[name] === 1 ? 400 : 5); // first try hangs
};
app.get("/api/v1/status", slowFirst("status"));
app.post("/api/v1/calls", slowFirst("dial"));
app.get("/api/v1/logs", (_req, res) => { hits.logs = (hits.logs ?? 0) + 1; setTimeout(() => res.json({ lines: 9000 }), 400); }); // always slow
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve)); // the port is known once it's listening
const base = `http://127.0.0.1:${server.address().port}`;

class RequestTimeoutError extends Error {
  constructor(method, path, ms, attempts, cause) {
    super(`${method} ${path}: no response within ${ms} ms (${attempts} attempt${attempts > 1 ? "s" : ""})` +
      (method === "POST" ? "; it may still have been applied" : ""), { cause });
    this.name = "RequestTimeoutError";
  }
}

async function send(method, path, { abortSignal, timeoutInMs = 100, maxRetries = 2 } = {}) {
  const idempotent = method === "GET" || method === "PUT" || method === "DELETE";
  for (let attempt = 0; ; attempt++) {
    const attemptSignal = AbortSignal.timeout(timeoutInMs);
    const signal = abortSignal ? AbortSignal.any([abortSignal, attemptSignal]) : attemptSignal;
    try {
      return await (await fetch(base + path, { method, signal })).json();
    } catch (err) {
      if (abortSignal?.aborted) throw abortSignal.reason; // the caller cancelled: stop now, never retry
      if (err.name !== "TimeoutError") throw err;
      if (!idempotent || attempt === maxRetries) throw new RequestTimeoutError(method, path, timeoutInMs, attempt + 1, err);
    }
  }
}

const show = async (label, promise) =>
  console.log(label.padEnd(24), await promise.then((v) => `ok ${JSON.stringify(v)}`, (e) => `${e.name}: ${e.message}`));

await show("GET status", send("GET", "/api/v1/status"));
await show("POST dial", send("POST", "/api/v1/calls"));
await show("GET logs (always slow)", send("GET", "/api/v1/logs"));
const controller = new AbortController();
setTimeout(() => controller.abort(), 30); // e.g. the user closed the page
await show("GET logs, cancelled", send("GET", "/api/v1/logs", { abortSignal: controller.signal, timeoutInMs: 1000 }));
console.log("device received:", JSON.stringify(hits));
server.closeAllConnections();
server.close();
Result
GET status               ok {"ok":true,"attempt":2}
POST dial                RequestTimeoutError: POST /api/v1/calls: no response within 100 ms (1 attempt); it may still have been applied
GET logs (always slow)   RequestTimeoutError: GET /api/v1/logs: no response within 100 ms (3 attempts)
GET logs, cancelled      AbortError: This operation was aborted
device received: {"status":2,"dial":1,"logs":4}
What’s happening
  1. The device's status and calls endpoints hang for 400 ms on their first request and answer in 5 ms after that; logs always takes 400 ms. The client's default per-attempt timeout is 100 ms, so every first attempt times out.
  2. Each attempt creates a fresh AbortSignal.timeout(timeoutInMs); a timeout is per attempt, not shared, so a retry gets its full 100 ms. If the caller passed its own abortSignal, AbortSignal.any combines the two, and fetch rejects when either fires.
  3. GET status timed out once, was retried because GET is idempotent, and succeeded: {"ok":true,"attempt":2}. POST dial timed out and was not retried; the RequestTimeoutError says (1 attempt); it may still have been applied, and the device's counter ("dial":1) shows the request did arrive. That sentence is the most useful thing the error can tell the caller: check the device's state before doing it again.
  4. GET logs is always slow, so all three attempts timed out ((3 attempts), and the device counted 3 of its 4 logs requests here). The original TimeoutError from fetch is kept as cause (new Error(message, { cause })), so nothing is lost for debugging.
  5. The last call passes the caller's own AbortController with a generous 1 000 ms timeout and aborts it after 30 ms (the user closed the page). The catch checks abortSignal?.aborted first and rethrows the caller's reason, an AbortError, without retrying: a cancel means "stop", never "try again". That's the 4th logs request the device received.
  6. Telling the two apart is the key design point: TimeoutError means "the device was too slow" (maybe retry), AbortError means "I don't want this any more" (never retry). The Azure guidelines add a subtle rule for long-running operations: cancelling the wait must not cancel the operation on the service; a firmware update keeps running on the device even if the page that started it closes.
AdvancedChoosing timeout values per call

Which timeout values?

Pick timeouts per kind of call, and document them:

  • Reads and small writes on a device on the local network: a few seconds per attempt is typical. Devices are slower than cloud servers, and a device busy starting a call may take a while to answer.
  • Long operations (firmware update, factory reset, reboot, log export) shouldn't be one long HTTP request at all: the device answers 202 Accepted with an operation resource and the SDK polls it, offering beginFirmwareUpdate() that returns a poller (see Long-running operations). The HTTP calls stay short; the overall wait can be minutes.
  • An overall deadline across retries as well as a per-attempt timeout, so "3 retries × 10 s plus backoff" can't become 45 s of spinner. The caller's abortSignal (perhaps AbortSignal.timeout(15_000)) is that deadline.
The dial request timed out

A partner's room-booking system calls device.dial("room-4@example.com"), which sends POST /api/v1/calls. After 10 s the SDK throws RequestTimeoutError. The partner asks you to "just make the SDK retry dial on timeouts". What do you say, and what would you change instead?

Show answer

Don't retry it blindly: the call may already be ringing. Make the operation safe to repeat or make its outcome checkable.

What’s happening
  1. A timeout says nothing about whether the device acted: the request may have been lost on the way in (nothing happened), or the device may have started dialling and the response was lost or late (the call exists). An automatic retry in the second case puts the room into a second call or rejects with "already in a call".
  2. Option one, the general fix: support an Idempotency-Key header on POST /calls. The SDK generates a key per dial() invocation and reuses it on retries, and the device returns the original result for a repeated key. Then the SDK can retry safely, as the reboot did in the retry model.
  3. Option two, when the device API can't change soon (old firmware in the field): after a timeout, the SDK or the application reads the state (GET /api/v1/calls/active) and only dials again if no call to that address exists. This is application-level recovery and should be explicit, not hidden in a retry loop.
  4. Meanwhile the error already says the right thing: it may still have been applied. The SDK's job is to make the uncertainty visible, not to guess. That's what Google's AIP-194 means by not retrying requests "in which repeated runs would cause unintended state changes".

#Versioning and releasing an SDK

Added

A device platform has three version numbers, and mixing them up is the most common confusion in this area. The SDK version (device-sdk-js@2.1.0) describes the library's public surface: its methods, types and behaviour. The device API version (/api/v1, plus perhaps a minor such as 1.3 advertised by the device) describes the wire contract (see Versioning strategies). The firmware or OS version (on Poly's devices, the public docs talk about PolyOS releases such as 4.x and 5.x) describes the software on the device. They move independently: a firmware release can add API 1.3 features without any SDK release, and an SDK bug fix ships without touching a device.

The mental model: the SDK version is a promise about your code, not about the device. When a developer upgrades from 2.1.0 to 2.2.0, semantic versioning promises that their code still compiles and behaves the same; it doesn't promise that a new feature works on a room still running old firmware. That second promise comes from a separate, documented support matrix and from capability discovery at run time.

Semantic Versioning 2.0.0 (semver.org, checked 27 October 2026) defines the first promise: given MAJOR.MINOR.PATCH, increment MAJOR for incompatible API changes, MINOR for functionality added in a backward-compatible manner, and PATCH for backward-compatible bug fixes. Version 0.y.z is for initial development, when "anything MAY change at any time"; 1.0.0 defines the public API. Pre-release versions (2.0.0-beta.2) sort before the release, and build metadata (+build.77) is ignored for precedence.

What counts as a breaking change in a JavaScript/TypeScript SDK is broader than "removed a method":

ChangeBumpWhy
Fix a bug without changing the surfacepatchnobody's code needs to change (unless they depended on the bug: say so in the changelog)
New method, new optional parameter or optionminorold calls still compile and work
New field in a returned objectminorreaders ignore what they don't use
New required parameter, or an optional one becomes requiredmajorexisting calls no longer compile
Remove or rename a method, option, error class or exportmajorexisting code breaks
Remove a field from a returned object, or change its typemajorreaders break
New value in a returned union type (an "enum")major in strict TypeScript, unless documented as openexhaustive switch statements stop compiling (shown below)
Change behaviour callers rely on (retry defaults, error class for a status, default timeout)usually majorthe code compiles but does something different
Drop support for a Node.js version or a firmware rangemajor by most libraries' policysemver doesn't say, but users experience it as breaking

This model compares the public surface of release 1.4.0 with the next one and works out the version number it needs:

semver-bump.mjs
// What version number does the next SDK release need? Compare the public surface of 1.4.0 with the new one.
// Each method lists its parameters (required or optional) and the fields of what it returns; enums list values.
const before = {
  methods: {
    getSettings: { params: { options: "optional" }, returns: ["roomName", "volume", "mode"] },
    updateSettings: { params: { patch: "required", options: "optional" }, returns: ["roomName", "volume", "mode"] },
    setVolume: { params: { level: "required" }, returns: [] },
    reboot: { params: { options: "optional" }, returns: ["operationId"] },
  },
  enums: { Mode: ["teams", "zoom", "standard"] },
};
const after = {
  methods: {
    getSettings: { params: { options: "optional" }, returns: ["roomName", "volume", "mode", "timeZone"] },
    updateSettings: { params: { patch: "required", options: "optional" }, returns: ["roomName", "volume", "mode", "timeZone"] },
    // setVolume removed
    reboot: { params: { reason: "required", options: "optional" }, returns: ["operationId"] },
    listCalls: { params: { options: "optional" }, returns: ["id", "remote"] },
  },
  enums: { Mode: ["teams", "zoom", "standard", "meet"] },
};

const changes = [];
const add = (level, what) => changes.push({ level, what });
for (const [name, old] of Object.entries(before.methods)) {
  const now = after.methods[name];
  if (!now) { add("major", `removed method ${name}()`); continue; }
  for (const [p, kind] of Object.entries(now.params)) {
    if (!old.params[p]) add(kind === "required" ? "major" : "minor", `${name}(): new ${kind} parameter '${p}'`);
    else if (old.params[p] === "optional" && kind === "required") add("major", `${name}(): '${p}' is now required`);
  }
  for (const f of now.returns.filter((f) => !old.returns.includes(f))) add("minor", `${name}(): returns new field '${f}'`);
  for (const f of old.returns.filter((f) => !now.returns.includes(f))) add("major", `${name}(): no longer returns '${f}'`);
}
for (const name of Object.keys(after.methods)) if (!before.methods[name]) add("minor", `new method ${name}()`);
for (const [e, values] of Object.entries(after.enums))
  for (const v of values.filter((v) => !before.enums[e].includes(v))) add("major", `enum ${e} gains '${v}' (breaks exhaustive switches)`);

const rank = { patch: 0, minor: 1, major: 2 };
for (const c of changes) console.log(`${c.level.padEnd(5)}  ${c.what}`);
const level = changes.reduce((max, c) => (rank[c.level] > rank[max] ? c.level : max), "patch");
const [maj, min, pat] = "1.4.0".split(".").map(Number);
const next = { major: `${maj + 1}.0.0`, minor: `${maj}.${min + 1}.0`, patch: `${maj}.${min}.${pat + 1}` }[level];
console.log(`\n1.4.0 -> ${next} (${level})`);
Result
minor  getSettings(): returns new field 'timeZone'
minor  updateSettings(): returns new field 'timeZone'
major  removed method setVolume()
major  reboot(): new required parameter 'reason'
minor  new method listCalls()
major  enum Mode gains 'meet' (breaks exhaustive switches)

1.4.0 -> 2.0.0 (major)
What’s happening
  1. Each surface describes, per method, its parameters (required or optional) and the fields it returns, plus the published enums. In a real repository you'd get this from a tool that extracts the .d.ts surface, such as Microsoft's API Extractor, which writes an "API report" file that code review diffs; here it's written by hand.
  2. The loop compares old with new, method by method, and records each change with a level. New returned field timeZone on two methods: minor (readers ignore it). New method listCalls(): minor.
  3. setVolume() disappeared: major. reboot() gained a required parameter reason: major, because every existing device.reboot() call stops compiling (or, in JavaScript, sends undefined).
  4. Mode gained 'meet': major, because a value appearing in a returned type breaks code that switches over every case, as the TypeScript example below shows. For an input type, adding an accepted value would be minor.
  5. The release level is the highest level found, so 1.4.0 → 2.0.0. If you changed the plan to keep setVolume() as a deprecated alias, make reason optional (defaulting to "unspecified") and publish Mode as an open union, every change would be minor and the release would be 1.5.0. That's the usual move: batch breaking changes into rare, planned majors, and ship compatible minors in between.
AdvancedNew enum values break TypeScript callers

A new enum value is a breaking change in TypeScript

Strict TypeScript code often proves it handled every case with a never check. That's good practice for application code, and it means a value added to a union the SDK returns breaks the build of everyone who did it. Here's the customer's code against 1.5.0, then the same code after an SDK release added "meet" (every ❌ is a real compiler error):

app-code.ts
// SDK 1.5.0 published this type:
type Mode = "teams" | "zoom" | "standard";

// A customer's code, written against 1.5.0, compiled with strict TypeScript:
function label(mode: Mode): string {
  switch (mode) {
    case "teams": return "Microsoft Teams Rooms";
    case "zoom": return "Zoom Rooms";
    case "standard": return "Standard (SIP/H.323)";
    default: {
      const unreachable: never = mode; // proves every case is handled
      return unreachable;
    }
  }
}
app-code-after-upgrade.ts
// SDK "1.6.0" added a value to the published union:
type Mode = "teams" | "zoom" | "standard" | "meet";

function label(mode: Mode): string {
  switch (mode) {
    case "teams": return "Microsoft Teams Rooms";
    case "zoom": return "Zoom Rooms";
    case "standard": return "Standard (SIP/H.323)";
    default: {
      const unreachable: never = mode; // ❌ TS2322 Type '"meet"' is not assignable to type 'never'
      return unreachable;
    }
  }
}
open-enum.ts
// Option: publish the union as "open" from day one, and say so in the docs.
type KnownMode = "teams" | "zoom" | "standard";
type Mode = KnownMode | (string & {}); // autocomplete still suggests the known values

function label(mode: Mode): string {
  switch (mode) {
    case "teams": return "Microsoft Teams Rooms";
    case "zoom": return "Zoom Rooms";
    case "standard": return "Standard (SIP/H.323)";
    default: return `Other (${mode})`; // a value newer firmware sends lands here, at compile time and at run time
  }
}
console.log(label("meet"));
What’s happening
  1. In the first block, after the three cases, TypeScript has narrowed mode to never in default, so const unreachable: never = mode compiles. It's an exhaustiveness check: if a case is missing, this line fails.
  2. In the second block the union has a fourth member and nothing handles it, so mode in default is "meet", and the compiler reports "TS2322 Type '"meet"' is not assignable to type 'never'". The customer's build broke on a "minor" upgrade, which is exactly what semver forbids.
  3. The third block shows the escape hatch: KnownMode | (string & {}). string & {} is still any string, but TypeScript doesn't collapse the union into plain string, so editors keep suggesting the three known values. The default branch is now required by the type, and it handles values the SDK doesn't know yet.
  4. This matters doubly on devices: newer firmware may send a mode an older SDK has never heard of. An open union, plus a default branch that shows "Other (meet)", keeps an old admin UI working against a new device. Decide open or closed for each union before 1.0.0 and document it.
AdvancedDeprecating before removing

The semver FAQ's answer for deprecation is two steps: update the documentation, then "issue a new minor release with the deprecation in place", and only remove the functionality in a later major release. In code that means three signals: a @deprecated JSDoc tag (TypeScript 4.0 and later recognise it, and editors such as VS Code show the name struck through; it's a hint, not a compile error), a runtime warning emitted once, and an entry in the changelog. A device SDK also has a second source of deprecations: the device itself, which may send the Deprecation header (RFC 9745, March 2025), a Sunset header with the date the endpoint will stop working (RFC 8594) and a Link with rel="deprecation" pointing to documentation (see Deprecation and sunset).

deprecations.mjs
// Deprecations in an SDK: a deprecated method warns once (and still works), and Deprecation / Sunset
// headers from the device (RFC 9745, RFC 8594) are surfaced as warnings instead of being ignored.
import express from "express";

const app = express().use(express.json());
const settings = { volume: 40 };
app.patch("/api/v1/settings", (req, res) => res.json(Object.assign(settings, req.body)));
app.get("/api/v1/diagnostics", (_req, res) => {
  res.set({ deprecation: "@1767225600", sunset: "Thu, 31 Dec 2026 23:59:59 GMT", link: '<https://device.example/docs/migrate-diagnostics>; rel="deprecation"' });
  res.json({ cpu: 31 });
});
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve)); // the port is known once it's listening
const base = `http://127.0.0.1:${server.address().port}`;

process.on("warning", (w) => console.log(`  [${w.name} ${w.code ?? ""}] ${w.message}`)); // where an app would log it
const warned = new Set();
const warnOnce = (key, message, code = key.split(" ")[0]) => {
  if (warned.has(key)) return;
  warned.add(key);
  process.emitWarning(message, { type: "DeprecationWarning", code });
};

const request = async (method, path, body) => {
  const res = await fetch(base + path, { method, headers: { "content-type": "application/json" }, body: body && JSON.stringify(body) });
  const deprecation = res.headers.get("deprecation");
  if (deprecation) {
    const since = new Date(Number(deprecation.slice(1)) * 1000).toISOString().slice(0, 10);
    const link = res.headers.get("link")?.match(/<([^>]+)>;\s*rel="deprecation"/)?.[1];
    warnOnce(`DEVSDK_DEVICE ${method} ${path}`, `${method} ${path} is deprecated by the device since ${since}; sunset ${res.headers.get("sunset")}. See ${link}`);
  }
  return res.json();
};

const device = {
  updateSettings: (patch) => request("PATCH", "/api/v1/settings", patch),
  /** @deprecated Since 1.5.0. Use `updateSettings({ volume })`. Removed in 2.0.0. */
  setVolume(level) {
    warnOnce("DEVSDK001", "setVolume() is deprecated since 1.5.0 and will be removed in 2.0.0; use updateSettings({ volume }) instead");
    return this.updateSettings({ volume: level });
  },
  getDiagnostics: () => request("GET", "/api/v1/diagnostics"),
};

for (const level of [50, 60, 70]) console.log("setVolume:", (await device.setVolume(level)).volume);
console.log("getDiagnostics:", (await device.getDiagnostics()).cpu);
console.log("getDiagnostics:", (await device.getDiagnostics()).cpu);
await new Promise((r) => setImmediate(r)); // warnings are emitted on the next tick
server.close();
Result
  [DeprecationWarning DEVSDK001] setVolume() is deprecated since 1.5.0 and will be removed in 2.0.0; use updateSettings({ volume }) instead
setVolume: 50
setVolume: 60
setVolume: 70
  [DeprecationWarning DEVSDK_DEVICE] GET /api/v1/diagnostics is deprecated by the device since 2026-01-01; sunset Thu, 31 Dec 2026 23:59:59 GMT. See https://device.example/docs/migrate-diagnostics
getDiagnostics: 31
getDiagnostics: 31
What’s happening
  1. warnOnce(key, message) emits a Node warning of type DeprecationWarning with a code, but only the first time for each key. Calling setVolume three times printed the warning once; a warning on every call would flood logs and teach people to filter warnings out, including the ones that matter.
  2. setVolume(level) still works (the three calls set 50, 60, 70): a deprecation is a notice, not a removal. Its JSDoc says when it was deprecated, what to use instead and when it goes away (Since 1.5.0 … Removed in 2.0.0), which is the information a developer needs to plan.
  3. request checks every response for a Deprecation header. @1767225600 is a Unix timestamp in seconds (RFC 9745's structured-field date), which is 2026-01-01; the warning also quotes the Sunset date and the documentation link. Two calls to getDiagnostics() produced one warning, keyed by method and path.
  4. The model prints warnings through process.on("warning"), which is where an application would hook its logger. Node also prints them to stderr by default, in the form (node:<pid>) [DEVSDK001] DeprecationWarning: …, and node --no-deprecation silences them; in a browser the SDK would call a configurable logger instead, since there's no process.
  5. Surfacing the device's headers is what makes the SDK useful during a firmware migration: an integrator who never reads release notes still sees, in their own logs, that an endpoint they call has a sunset date on the device they're talking to.
AdvancedSupporting a fleet on old firmware

A real fleet runs several firmware versions at once (a migration from one OS generation to the next takes months, and some rooms never get updated). One SDK major should support a documented range of them, and fail fast with a clear message when a feature isn't available, instead of sending a request that comes back as a puzzling 404. That means asking the device what it supports (see Capability discovery):

capabilities.mjs
// One SDK, a fleet on different firmware. The SDK reads what each device supports once, then fails fast with a
// clear error for features an older device doesn't have, instead of sending a request that 404s.
import express from "express";

async function fakeDevice(info) {
  const app = express();
  const seen = [];
  app.use((req, _res, next) => (seen.push(`${req.method} ${req.path}`), next()));
  app.get("/api/info", (_req, res) => res.json(info));
  if (info.capabilities.includes("recording")) app.post("/api/v1/recording", (_req, res) => res.status(201).json({ recordingId: "r1" }));
  const server = app.listen(0, "127.0.0.1");
  await new Promise((resolve) => server.once("listening", resolve)); // the port is known once it's listening
  return { server, seen, baseUrl: `http://127.0.0.1:${server.address().port}` };
}

class UnsupportedFeatureError extends Error {
  constructor(feature, required, info) {
    super(`'${feature}' needs device API ${required}+; this device runs API ${info.apiVersion} (firmware ${info.firmware})`);
    this.name = "UnsupportedFeatureError";
  }
}

class DeviceClient {
  #baseUrl; #info;
  constructor(baseUrl) { this.#baseUrl = baseUrl; }
  async #capabilities() {
    this.#info ??= await (await fetch(`${this.#baseUrl}/api/info`)).json(); // fetched once per client
    return this.#info;
  }
  async supports(feature) { return (await this.#capabilities()).capabilities.includes(feature); }
  async startRecording() {
    const info = await this.#capabilities();
    if (!info.capabilities.includes("recording")) throw new UnsupportedFeatureError("startRecording", "1.3", info);
    return (await fetch(`${this.#baseUrl}/api/v1/recording`, { method: "POST" })).json();
  }
}

const fleet = {
  "lobby (old firmware)": await fakeDevice({ apiVersion: "1.1", firmware: "4.2.1", capabilities: ["settings", "calls"] }),
  "board room": await fakeDevice({ apiVersion: "1.3", firmware: "5.0.0", capabilities: ["settings", "calls", "recording"] }),
};
for (const [name, dev] of Object.entries(fleet)) {
  const client = new DeviceClient(dev.baseUrl);
  console.log(`${name}: supports recording? ${await client.supports("recording")}`);
  try {
    console.log("  started:", JSON.stringify(await client.startRecording()));
  } catch (err) {
    console.log(`  ${err.name}: ${err.message}`);
  }
  console.log("  device saw:", dev.seen.join(", "));
  dev.server.close();
}
Result
lobby (old firmware): supports recording? false
  UnsupportedFeatureError: 'startRecording' needs device API 1.3+; this device runs API 1.1 (firmware 4.2.1)
  device saw: GET /api/info
board room: supports recording? true
  started: {"recordingId":"r1"}
  device saw: GET /api/info, POST /api/v1/recording
What’s happening
  1. fakeDevice(info) starts a device that answers GET /api/info with its API version, firmware version and capability list, and only implements POST /api/v1/recording if recording is in the list, like real old firmware that has never heard of the endpoint.
  2. DeviceClient fetches /api/info once and caches it (??=), so checking capabilities costs one request per client, not one per call. supports(feature) exposes the check to applications, so the admin UI can hide a "Record" button instead of showing one that fails.
  3. On the lobby device (API 1.1, firmware 4.2.1), startRecording() throws UnsupportedFeatureError before sending anything: the message names the method, the API version it needs (1.3+), and what the device runs. The device saw only GET /api/info.
  4. On the board-room device (API 1.3, firmware 5.0.0), the same SDK call goes through and returns {"recordingId":"r1"}. The same SDK version serves both rooms; the difference is decided at run time from what each device says, not by installing a different SDK per firmware.
  5. If the device can't advertise capabilities (very old firmware), the fallback is a version table in the SDK ("recording needs API 1.3") and, last resort, mapping a 404 on that path to UnsupportedFeatureError. Either way the user sees "this room's firmware doesn't support recording", not "Not Found".

A support matrix makes the promise explicit. An illustrative one (these numbers are this course's example, not any vendor's):

SDKDevice API versionsFirmwareNode.jsStatus
2.x1.1 – 1.34.2 and later20, 22, 24current; features needing 1.3 throw UnsupportedFeatureError on older devices
1.x1.0 – 1.24.0 – 4.x18, 20security fixes only until a published date, then deprecated on npm
AdvancedSemver precedence and pre-release channels

Semver precedence, and pre-release channels

Pre-releases let partners try the next major against their integration before it's final: 2.0.0-alpha, 2.0.0-beta.1, 2.0.0-rc.1, then 2.0.0. The ordering rules (semver item 11) trip people up, so here they are implemented directly:

semver-sort.mjs
// Semantic Versioning 2.0.0 precedence (spec item 11), implemented directly, then used to sort releases.
function parse(v) {
  const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/);
  if (!m) throw new Error(`not semver: ${v}`);
  return { core: [+m[1], +m[2], +m[3]], pre: m[4] ? m[4].split(".") : [] }; // build metadata (m[5]) is ignored
}
function compare(a, b) {
  const x = parse(a), y = parse(b);
  for (let i = 0; i < 3; i++) if (x.core[i] !== y.core[i]) return x.core[i] - y.core[i]; // numeric, not text
  if (!x.pre.length || !y.pre.length) return y.pre.length - x.pre.length; // a pre-release sorts before the release
  for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
    const p = x.pre[i], q = y.pre[i];
    if (p === undefined) return -1; // fewer identifiers, all equal so far: lower
    if (q === undefined) return 1;
    const pn = /^\d+$/.test(p), qn = /^\d+$/.test(q);
    if (pn && qn && +p !== +q) return +p - +q; // numeric identifiers compare as numbers
    if (pn !== qn) return pn ? -1 : 1; // numeric identifiers sort before alphanumeric ones
    if (!pn && p !== q) return p < q ? -1 : 1; // otherwise ASCII order
  }
  return 0;
}

const releases = ["2.0.0", "1.10.0", "2.0.0-rc.1", "1.9.3", "2.0.0-beta.11", "2.0.0-beta.2", "2.0.0-alpha", "2.0.0-alpha.1", "2.0.0-beta", "1.9.3+build.77"];
const sorted = releases.sort(compare);
console.log(sorted.map((v, i) => (i === 0 ? "  " : compare(sorted[i - 1], v) === 0 ? "= " : "< ") + v).join("\n"));
console.log('plain string sort would say "1.10.0" < "1.9.3":', "1.10.0" < "1.9.3");
Result
  1.9.3
= 1.9.3+build.77
< 1.10.0
< 2.0.0-alpha
< 2.0.0-alpha.1
< 2.0.0-beta
< 2.0.0-beta.2
< 2.0.0-beta.11
< 2.0.0-rc.1
< 2.0.0
plain string sort would say "1.10.0" < "1.9.3": true
What’s happening
  1. parse splits a version into the three numeric core parts and the dot-separated pre-release identifiers, and drops build metadata after +. 1.9.3+build.77 therefore compares equal to 1.9.3 (the = in the output); build metadata is for traceability only.
  2. Core parts compare as numbers, so 1.9.3 < 1.10.0. The last line shows why that matters: as strings, "1.10.0" < "1.9.3" is true, which is the bug in every hand-rolled "is this firmware new enough?" check that compares version strings.
  3. A version with a pre-release sorts before the same version without one: all the 2.0.0-… versions come before 2.0.0. Two pre-releases compare identifier by identifier: alpha < alpha.1 (fewer identifiers, all equal so far, is lower), alpha.1 < beta (ASCII order of alpha and beta), and beta.2 < beta.11 because numeric identifiers compare as numbers.
  4. In npm terms, pre-releases are published under a dist-tag so ordinary users don't get them: npm publish --tag next, and partners opt in with npm install device-sdk-js@next. A ^2.0.0 range in a partner's package.json won't pick up 2.1.0-beta.1 either: npm's ranges skip pre-releases unless the range itself names a pre-release of the same major.minor.patch (checked with the node-semver 7.8.5 bundled with npm: ^2.0.0 → false, ^2.1.0-beta.0 → true).
AdvancedThe release pipeline, step by step

The release itself

A release is a pipeline, not a person running npm publish from a laptop:

  • Changelog. Keep one in the format of Keep a Changelog 1.1.0 (keepachangelog.com): newest version first, a date on each, changes grouped as Added, Changed, Deprecated, Removed, Fixed and Security, an "Unreleased" section collecting changes between releases, and a statement that the project follows semver.
  • API surface check. CI extracts the public .d.ts surface (for example with API Extractor) and fails if it changed in a way the version bump doesn't allow, the way semver-bump.mjs decides.
  • Test against devices. The SDK's tests run against a simulated device for each supported API version (1.1, 1.2, 1.3), and, before a release, against real devices in a lab (see Testing and Delivering Device Software).
  • Publish with provenance. npm can attach a signed provenance statement linking the package to the commit and the CI run that built it: npm publish --provenance (npm CLI 9.5.0+) from GitHub Actions with id-token: write, or automatically with npm's trusted publishing, which needs npm CLI 11.5.1+ and generates provenance automatically for public packages published from public repositories (docs.npmjs.com, "Trusted publishing for npm packages", last edited 6 October 2026). Partners who audit their supply chain will ask.
  • Deprecate old versions on the registry. npm deprecate device-sdk-js@"1.x" "1.x is unsupported since 2027-06-30; upgrade to 2.x: <migration guide URL>" makes every install of those versions print the message; an empty message ("") undoes it.

A changelog entry for the release in this topic:

CHANGELOG.md
## [1.5.0] - 2026-11-03

### Added
- `listCalls()` returns an async iterator over the call history; `byPage()` resumes from a continuation token.
- `timeZone` is returned by `getSettings()` and `updateSettings()` (device API 1.3 and later).

### Deprecated
- `setVolume(level)`: use `updateSettings({ volume })`. It logs warning DEVSDK001 once and will be removed in 2.0.0.

### Fixed
- A session was re-created twice when two calls got 401 at the same time after a device reboot.
Is it breaking?

Your SDK is at 3.2.0. For each change, which version comes next? (a) getStatus() now also returns uptimeSeconds; (b) reboot(options?) becomes reboot(reason, options?); (c) the default timeout drops from 30 s to 5 s; (d) a typo in an error message is fixed.

Show answer

(a) 3.3.0, (b) 4.0.0, (c) 4.0.0 in practice, (d) 3.2.1.

What’s happening
  1. (a) A new field in a returned object is a backward-compatible addition: code that reads the old fields is unaffected, so it's a minor, 3.3.0.
  2. (b) A new required positional parameter breaks every existing reboot() call at compile time in TypeScript, and sends undefined as the reason in JavaScript. That's an incompatible change, 4.0.0. The compatible design is reboot({ reason }) in the options object, which would be 3.3.0.
  3. (c) Nothing stops compiling, but calls that took 10 s (a slow device generating a log bundle) now fail. Semver talks about the public API, and documented defaults are part of it; most SDK teams treat behaviour changes callers depend on as major, or ship them as a new opt-in option in a minor and flip the default in the next major.
  4. (d) A wording fix is a patch, 3.2.1, as long as you never promised the message text. That's another reason to give errors a stable code: people who match on err.message turn every wording fix into a breaking change.

#Documentation and examples developers actually use

Added

Developers rarely read SDK documentation from the top. They arrive with a task ("set every room's time zone"), look for an example close to it, copy it, and then live in their editor, reading types, hover docs and, when something fails, the error message. So the documentation that matters most is the example, the type signature and the error message, in that order, and all three have to be right for the version they installed.

The Diátaxis framework (diataxis.fr, checked 27 October 2026) gives a useful map: four kinds of documentation for four needs. Tutorials are lessons, for learning ("your first call to a device"). How-to guides address a real-world goal or problem ("roll a setting out to a fleet", "handle a device reboot during an update"). Reference is the technical description (every method, option, error class and code), and for an SDK it should be generated from the types and doc comments so it can't drift. Explanation provides context and background (how sessions work, why some calls aren't retried, how capability discovery works). Most bad SDK docs are reference pretending to be all four.

The mental model: docs are part of the product, so they get tests. An example that no longer compiles is a bug report waiting to happen, and examples rot silently: the method is renamed in a major, the README isn't. The fix is to run the examples, the same way this site re-runs every model on each build:

doctest.mjs
// Docs that are tested: pull every ```js example out of the README, run it against a fake device,
// and check each `// → value` comment against what the example really printed.
const fence = "`".repeat(3);
const readme = `
# device-sdk-js 2.0

## Quick start
${fence}js
const settings = await device.getSettings();
console.log(settings.volume); // → 40
${fence}

## Change a setting
${fence}js
await device.setVolume(55);
console.log((await device.getSettings()).volume); // → 55
${fence}

## Handle errors
${fence}js
try {
  await device.updateSettings({ volume: 140 });
} catch (err) {
  console.log(err.name); // → ValidationError
  console.log(err.fields[0].pointer); // → #/volume
}
${fence}
`;

// The SDK as released in 2.0.0 (in-memory fake: no network). setVolume() was removed in 2.0.0.
class ValidationError extends Error { name = "ValidationError"; constructor(fields) { super("Invalid settings"); this.fields = fields; } }
function fakeDevice() {
  const state = { roomName: "Huddle 2", volume: 40 };
  return {
    getSettings: async () => ({ ...state }),
    updateSettings: async (patch) => {
      if (patch.volume > 100) throw new ValidationError([{ pointer: "#/volume", detail: "must be 0 to 100" }]);
      return Object.assign(state, patch);
    },
  };
}

const AsyncFunction = (async () => {}).constructor;
const examples = [...readme.matchAll(/^## (.+)\n```js\n([\s\S]*?)^```/gm)];
let failed = 0;
for (const [, heading, code] of examples) {
  const expected = [...code.matchAll(/\/\/ → (.*)$/gm)].map((m) => m[1].trim());
  const printed = [];
  const fakeConsole = { log: (...args) => printed.push(args.join(" ")) };
  let problem = null;
  try {
    await new AsyncFunction("device", "console", code)(fakeDevice(), fakeConsole);
    if (printed.join("|") !== expected.join("|")) problem = `printed [${printed}] but the docs say [${expected}]`;
  } catch (err) {
    problem = `threw ${err.name}: ${err.message}`;
  }
  if (problem) failed++;
  console.log(`${problem ? "✗" : "✓"} ${heading}${problem ? ` — ${problem}` : ""}`);
}
console.log(`${examples.length} examples, ${failed} failing`);
Result
✓ Quick start
✗ Change a setting — threw TypeError: device.setVolume is not a function
✓ Handle errors
3 examples, 1 failing
What’s happening
  1. readme is a README for release 2.0 with three sections, each holding one js example. Lines ending in // → value say what the example prints; they double as documentation for the reader and as assertions for the test. (The fences are built with "`".repeat(3) only so this example can contain a README with code blocks.)
  2. fakeDevice() stands in for the 2.0.0 SDK talking to a device, in memory: getSettings and updateSettings work, a volume over 100 throws a ValidationError with fields, and setVolume() doesn't exist because 2.0.0 removed it.
  3. The runner finds each ## heading followed by a js block, collects the expected // → values, and runs the code as the body of an async function (AsyncFunction) with two parameters, device and a fake console that records what's printed. Top-level await works because the body is an async function.
  4. "Quick start" printed 40, as promised: ✓. "Handle errors" printed ValidationError and #/volume: ✓. "Change a setting" still calls device.setVolume(55), which was removed in 2.0.0, so it threw TypeError: device.setVolume is not a function: ✗. That README would have shipped with a broken example, and the first partner to copy it would have filed a bug.
  5. In CI this runner would set process.exitCode = 1 on any failure so the release is blocked (here it exits 0 so the page's checker can run it). Real projects get the same effect by keeping examples as real files compiled and run in CI, and including them into the docs, rather than pasting code into Markdown.
AdvancedWhat a good example contains
  • It runs as written. Imports, the client construction, real-looking values (https://10.0.0.21, a room name), and await at the top level of an ES module. A reader shouldn't have to guess what client is.
  • It handles the error that will actually happen. For a device, that's the device being unreachable, the session being rejected and validation failing. Showing the catch teaches the error model better than a page about it.
  • It cleans up. Closes the session if the API has a logout (devices often limit sessions), stops subscriptions, aborts long polls.
  • It says what it needs. "Requires device API 1.3 (firmware 5.0 or later)" above the example, so nobody tries a recording sample on a lobby device running 4.2.
  • It's short and does one task. A how-to per task beats one giant sample app; keep a sample app too, for people who learn from a complete project.
  • It works without hardware. Point the quick start at a device simulator (a container or npx command that serves the same API) so a partner can try the SDK before a device arrives, and so CI can run the examples.
AdvancedDoc comments that editors show

Docs in the editor: doc comments that earn their place

Most developers will read the reference in a hover tooltip, not on a website. TypeScript and editors read JSDoc/TSDoc comments, so put the useful facts there: what the call does on the device, the side effects, the errors it throws, which firmware it needs, and a tiny example. Reference sites (TypeDoc for TypeScript is the common generator) are built from the same comments. This block compiles under strict TypeScript:

device-client.d.ts
export interface OperationOptions {
  abortSignal?: AbortSignal;
  timeoutInMs?: number;
}
export interface RebootOperation {
  operationId: string;
  /** Resolves when the device is back online and answering API calls. */
  pollUntilDone(options?: OperationOptions): Promise<void>;
}

export declare class DeviceClient {
  /**
   * Restarts the device. It's unreachable for about a minute, and every admin session on it is lost.
   *
   * Starts a long-running operation and returns as soon as the device accepts it (`202 Accepted`).
   * Safe to retry: the SDK sends an `Idempotency-Key`, so a retried request doesn't reboot twice.
   *
   * @param options.reason - Recorded in the device's audit log.
   * @throws {ConflictError} A call is in progress (problem type `in-call`); end it first.
   * @throws {PermissionError} The account's role can't reboot devices.
   * @since 1.2.0 · device API 1.1 or later
   * @example
   * const op = await device.beginReboot({ reason: "weekly maintenance" });
   * await op.pollUntilDone({ abortSignal: AbortSignal.timeout(180_000) });
   */
  beginReboot(options?: OperationOptions & { reason?: string }): Promise<RebootOperation>;
}
What’s happening
  1. The first paragraph says what happens on the device, including the side effects a developer wouldn't guess: the device is gone for about a minute and every admin session on it is lost (so the SDK will log in again afterwards). That's the sentence that stops a partner's script treating a minute of errors as a crash.
  2. The second paragraph explains the shape: a begin… method that returns an operation with pollUntilDone, and the retry behaviour. A developer deciding whether to wrap it in their own retry loop finds the answer in the tooltip.
  3. @throws lists the typed errors and the problem type behind them (in-call is the ConflictError from the error-mapping model: the device refuses to reboot during a call), so the catch the developer writes matches what really comes back. @since gives both the SDK version and the device API it needs.
  4. @example shows the realistic call, including a three-minute overall deadline with AbortSignal.timeout(180_000). Editors show it in the hover; TypeDoc renders it on the reference page.
  5. Because it's a .d.ts file, it's the exact shape the package ships, and the compiler checks it: if RebootOperation or the options type changed and the comment didn't, the types would still be right and only the prose could drift, which is why the prose stays short and factual.
AdvancedError messages are documentation too

The message a developer sees at 6 p.m. on a Friday is the only documentation they'll read that day. Make every message say what happened, why, and what to do, and give it a stable code plus a link:

  • Bad: Error: Request failed with status 409.
  • Good: ConflictError: Cannot reboot during a call: End call c41 first (code IN_CALL, request req-d). See https://device.example/problems/in-call.

The problem type URI is a natural link target: make each type resolve to a page that explains the error and how to fix it. RFC 9457 allows type to be a dereferenceable URL for exactly this reason.

AdvancedVersioned docs and migration guides

Docs must match the installed version, so publish them per major (/docs/2.x/, /docs/1.x/), mark the current one, and keep the old ones online while that major is supported; a partner stuck on 1.x because of old firmware deserves docs that match their code. Every major gets a migration guide listing each breaking change with a before/after snippet and the reason, in the order people will hit them; it's the most-read page in the first month after a major.

Why did the docs break when nothing in the README changed?

A partner reports that the README's "Change a setting" example fails with TypeError: device.setVolume is not a function. Nobody touched the README in months. What happened, and what process change would have caught it?

Show answer

The SDK changed under the README: 2.0.0 removed setVolume(), and the example wasn't tested against the release.

What’s happening
  1. The README was written for 1.x, when setVolume() existed. 1.5.0 deprecated it (with a warning the README's readers never saw, because the example was never run), and 2.0.0 removed it as the deprecation promised. The text stayed the same; the code it describes didn't.
  2. Untested examples drift whenever the API changes, and the drift is invisible until a user copies the code. The doctest.mjs model shows the fix: run every example against the release candidate, compare the printed values with the documented ones, and fail the build on any mismatch.
  3. Running it on the 2.0.0 README gives ✓ Quick start, ✗ Change a setting — threw TypeError: device.setVolume is not a function, ✓ Handle errors: the bug is found before release instead of by a partner.
  4. The process change: examples live as files that CI compiles and runs (or are extracted from Markdown and run, as in the model), the release pipeline blocks on failures, and the migration guide for 2.0.0 lists setVolume(level) → updateSettings({ volume: level }).

Written fresh for a device-platform full stack role. Runnable examples were run on Node 26; product facts come from public documentation, cited where used.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.