Skip to content

Migrating from the JS reference client

Code-by-code translation of the patterns the JS client made you write by hand into the SDK's equivalent. Ctrl-F any JS idiom and find the SDK answer.

Login

Before (JS)

const r = await axios.post('/api/v2/auth/log_in.json', {
  email: 'qa@example.com',
  password: 'hunter2',
});
const token = r.data.data.token;
localStorage.setItem('USER_TOKEN', token);
axios.defaults.headers.common['X-User-Token'] = token;
axios.defaults.headers.common['X-User-Email'] = r.data.data.email;

After (SDK)

from rtls_sdk import RtlsClient
client = RtlsClient.from_env()  # reads RTLS_USERNAME / RTLS_PASSWORD / RTLS_BASE_URL

Why this is better: No global axios mutation, no manual token plumbing, no token persistence. Lazy: the actual login fires on the first API call.


401 handling

Before (JS)

axios.interceptors.response.use(null, async err => {
  if (err.response?.status === 401 && !err.config.__isRetry) {
    err.config.__isRetry = true;
    const token = await refreshToken();
    err.config.headers['X-User-Token'] = token;
    return axios(err.config);
  }
  throw err;
});

After (SDK)

Nothing. The SDK handles 401 internally — on a 401 it re-authenticates with the stored credentials and replays the request. The caller sees the eventual success or an AuthenticationError.

Why this is better: No interceptor maintenance, no failedRequestsQueue-style coalescing logic, no shared mutable state to debug.


Compound: tag create + zone attach + groups bind

Before (JS)

const tag = await axios.post('/api/v2/trackable_objects', {
  name: 'Forklift 9',
  // ⚠ JS sent mac_address here but the server's createTrackObject schema
  // silently strips unknown keys — the MAC never actually bound. The
  // intended binding is via /trackable_objects_associations.
});
await axios.put(`/api/v2/trackable_objects/${tag.data.uid}/zone`, {
  zone_uid: 'z-warehouse-3',
});
await axios.put(`/api/v2/trackable_objects/${tag.data.uid}/groups`, [
  'g-fleet', 'g-priority',
]);
// If any step failed, the half-created tag is now orphaned — caller's
// problem to clean up.

After (SDK)

tag = client.tags.create(
    name="Forklift 9",
    attached_zone={"shape": "circle", "geometry": {"radius": 3000}},
    groups=["g-fleet", "g-priority"],
)
# Bind hardware separately (tag.create has no mac_address parameter):
client.nodes.create("aabbccddeeff")
client.tag_associations.create("aabbccddeeff", tag.uid)

Why this is better: One call (per saga) and one return. On partial failure the SDK rolls back the created tag and raises PartialFailureError with step="attach_zone" so a retry knows where to resume. No orphaned records. And the MAC binding is no longer silent — the SDK exposes the three primitives the server actually implements.


Scope juggling — per-call project switch

Before (JS)

function setProjectUidToHeader(uid) {
  axios.defaults.headers.common['X-User-Project'] = uid;
}
function removeProjectFromHeader() {
  delete axios.defaults.headers.common['X-User-Project'];
}

setProjectUidToHeader('p-target');
try {
  await axios.post('/api/v2/nodes_associations/release', {...});
  // If this throws, the header is leaked to every subsequent call
  // because the catch path doesn't run cleanup.
} finally {
  removeProjectFromHeader();
}

After (SDK)

client.with_scope(project_uid="p-target").nodes.release(node_uid="n-1")

Why this is better: Scope override lives on a per-thread threading.local, applied per-request. Header restoration is automatic and exception-safe. Two threads doing parallel scope switches don't race each other.


Pagination — following nextPage

Before (JS)

const events = [];
let url = '/api/v2/external_events?...';
while (url) {
  const r = await axios.get(url);
  events.push(...r.data.events);
  url = r.data.nextPage;  // null when done
}

After (SDK)

for event in client.events.iter_pws(trackable_uid="t-1", start=start, end=end):
    process(event)

Why this is better: The SDK handles all three server pagination styles (nextPage body URL, content-next-positions-page header cursor, bare page+limit) behind one iterator. Memory stays bounded even for year-long windows.


Array query params (Rails-style)

Before (JS)

// hand-built — easy to forget the brackets
const qs = trackUids.map(u => `track_uid[]=${u}`).join('&');
await axios.get(`/api/v2/external_events?${qs}&...`);

After (SDK)

client.events.iter_pws(trackable_uid=["t-1", "t-2"], start=start, end=end)

Why this is better: Pass a list. The SDK encodes track_uid[]=t-1&track_uid[]=t-2 correctly.


Envelope-unwrap fallback

Before (JS)

const things = r.data.things || r.data;  // "either shape"

After (SDK)

sites = client.sites.list()  # always list[Site]

Why this is better: Typed return. The SDK knows which envelope each endpoint uses and unwraps consistently. Unknown shapes raise loudly instead of silently returning the wrong type.


Timestamps

Before (JS)

// alarms — uses epoch ms
await axios.get('/api/v2/alarms', { params: { from: Date.now() - 86400e3, to: Date.now() } });
// reports — uses ISO-8601
await axios.get('/api/v2/report', { params: { from: '2024-01-01T00:00:00Z', to: '2024-01-02T00:00:00Z' } });

After (SDK)

from datetime import datetime, timezone
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
end = datetime(2024, 1, 2, tzinfo=timezone.utc)

client.alarms.list(start=start, end=end)   # SDK sends epoch ms
client.reports.list(start=start, end=end, site_uid="s-1", type=ReportType.HEATMAP)  # SDK sends ISO-8601

Why this is better: Always a tz-aware datetime. The SDK picks the right wire format per endpoint.


Token storage

Before (JS)

localStorage.setItem('USER_TOKEN', token);
const token = localStorage.getItem('USER_TOKEN');

After (SDK)

Nothing — tokens are instance-scoped and never persisted. Construct a new RtlsClient per identity. When the process exits, the token is gone.


Swallowed errors

Before (JS)

async function createTrackableAssociations(pairs) {
  try {
    await axios.post('/api/v2/associations/bulk', pairs);
  } catch (e) {
    console.error(e);
    return null;  // caller can't distinguish "no associations" from "all failed"
  }
}

After (SDK)

result = client.tag_associations.bulk_create(pairs)
# result.successes and result.failures both populated

Why this is better: Typed BulkResult lists which items succeeded and which raised, so a partial batch doesn't lose state. No return-null-on-error.


Refresh failure UX

Before (JS)

catch (refreshErr) {
  window.location.reload();
}

After (SDK)

except AuthenticationError:
    ...   # caller decides — reconstruct client, re-prompt user, retry, etc.

Globals to avoid

The JS client tracks several pieces of state on the window or axios.defaults. None of them have an SDK analogue:

JS SDK answer
axios.defaults.baseURL = ... RtlsClient(base_url=...) per instance.
axios.defaults.headers.common['X-User-Token'] httpx.Auth injects per-request, no client state.
_getHostName() reading localStorage base_url is a constructor arg.
Browser-only target_server URL-param routing Not ported (server team confirmed).
setInterval(..., 3min) widget polls Out of scope for v1 — write your own loop.
Client-side zone-type filtering by role Not ported. The SDK returns what the server returns; filter in your application code.

A complete migration

The shortest before-and-after — typical dashboard bootstrap:

Before (JS)

await login(email, password);
const sites = await fetchSites();
const tags = await fetchTags();
const users = await fetchUsers();
const me = await fetchMe();
// 17 more list calls

After (SDK)

client = RtlsClient.from_env()
ctx = client.context.load()   # one call, all twenty sub-fetches

See Context for the full field list.