12 KiB
| type | title | created | tags | related | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| reference | API Design and Integration Patterns | 2026-06-26 |
|
|
API Design and Integration Patterns
This document describes how Tetromino integrates with the Are.na REST API. It is intended for contributors who need to understand, debug, or extend the API layer (src/api.ts).
For a high-level architecture overview, see DEVELOPER_GUIDE. For end-user troubleshooting of API errors, see USER_GUIDE.
Table of Contents
- Are.na API Overview
- Endpoints Used by Tetromino
- Authentication
- Pagination
- Rate Limiting
- Error Handling and Retries
- Response Normalisation
- Asset Downloads
- Caching
- Future API Versions
Are.na API Overview
Tetromino targets the Are.na REST API v3. The official documentation lives at https://www.are.na/developers/explore.
| Attribute | Value |
|---|---|
| Base URL | https://api.are.na |
| Version prefix | /v3 (auto-prepended by ArenaApi.buildApiUrl) |
| Transport | Obsidian's requestUrl (XMLHttpRequest under the hood) |
| Serialisation | JSON request/response bodies |
| Authentication | Bearer token (Authorization: Bearer <token>) |
All paths passed to ArenaApi.request are normalised: if they do not start with /v, the /v3 prefix is injected automatically. This means internal callers use human-readable paths such as /channels/my-channel while the actual request targets https://api.are.na/v3/channels/my-channel.
Endpoints Used by Tetromino
GET /channels/{slug}
Purpose: Fetch channel metadata (title, slug, description, length, visibility).
Called by: ArenaApi.getChannel()
Response shape (v3):
{
"data": {
"id": 123,
"title": "Inspiration",
"slug": "inspiration",
"description": "Visual references.",
"status": "public",
"length": 42,
"user": { /* … */ }
}
}
Normalisation: ArenaApi.normalizeChannel coerces length from multiple possible fields (length, total_connections, connections_count) and normalises metadata.description into a nullable metadata object.
GET /channels/{slug}/contents?page={n}&per={100}
Purpose: Fetch paginated blocks for a channel.
Called by: ArenaApi.getChannelContents() and ArenaApi.getAllChannelBlocksWithProgress()
Response shape (v3):
{
"data": [ /* ArenaBlock objects */ ],
"meta": {
"current_page": 1,
"per_page": 100,
"total_pages": 3,
"total_count": 201
}
}
Normalisation: ArenaApi.normalizePaginatedResponse converts this into a stable ArenaPaginatedResponse<ArenaBlock>.
GET /blocks/{id}
Purpose: Fetch a single block (used for enrichment: comments, connected channels, full metadata).
Called by: ArenaApi.getBlock()
Response shape (v3):
{
"data": { /* ArenaBlock */ }
}
GET /me
Purpose: Verify the user's API token.
Called by: ArenaApi.verifyToken()
Behaviour: Returns true if the request succeeds (2xx), false if any error is caught. Used by the settings-tab "Verify" button.
GET /me/channels?page={n}&per={100}
Purpose: List channels owned by the authenticated user.
Called by: ArenaApi.listMyChannels() and ArenaApi.listAllMyChannels()
Response shape: Same paginated envelope as /channels/{slug}/contents but with ArenaChannelListItem objects.
Direct asset URLs (not api.are.na)
Purpose: Download binary attachments and images.
Called by: ArenaApi.downloadBinary()
These requests target CDN URLs extracted from block fields (image.original.url, attachment.url, etc.). They are not prefixed with /v3 and do not include the Are.na auth token, because the URLs are pre-signed or publicly accessible.
Authentication
Tetromino uses a Personal Access Token supplied by the user in plugin settings.
- Generation: https://www.are.na/developers/personal-access-tokens
- Storage: Masked in the UI; persisted in Obsidian's plugin data (
loadData/saveData). - Header: Every API request includes
Authorization: Bearer <token>viaArenaApi.headers(). - Scope required: Read access (the plugin never writes to Are.na).
If the token is empty, the header is omitted. Most endpoints will then return 401 Unauthorized for private channels, but public channels may still be readable.
Pagination
Page Size
const PER_PAGE = 100;
Tetromino requests the maximum page size supported by Are.na (100 items) to minimise round-trips.
Fetch Strategy
getAllChannelBlocksWithProgress implements an eager, sequential fetch loop:
- Start at page
1. - Request the page via
fetchPageWithRetries(see Error Handling). - Deduplicate blocks by
idusing aSet<number>. - Report progress via
onPage(currentPage, totalPages). - Decide whether to continue based on
shouldStopPagination. - Sort the final, deduplicated array by
positionascending, thenidascending.
Stop Conditions
Pagination stops when any of the following is true:
| Condition | Meaning |
|---|---|
Empty page (pageLength === 0) |
No more blocks available. |
Partial page (pageLength < PER_PAGE) |
Last page naturally reached. |
Total pages reached (pageNumber >= reportedTotalPages) |
Server-reported bound reached. |
Duplicate page (newBlocksCount === 0 && pageLength > 0) |
Every block on this page was already seen (defensive against API overlap bugs). |
These conditions are logged at debug level with the exact reason (empty, partial, total, duplicates).
User Channel Listing
listAllMyChannels uses a simpler do … while loop that walks pages until page <= totalPages. Deduplication is not performed here because user channels are expected to be unique per page.
Rate Limiting
Are.na returns HTTP 429 Too Many Requests when the caller exceeds its rate limit. The response includes a Retry-After header (seconds).
Tetromino's Rate-Limit Handler
On 429:
- Read
Retry-Afterfrom the response headers (case-insensitive:retry-after,Retry-After,retry_after). - Default to
60seconds if the header is missing or malformed. - Log the delay at debug level.
- Wait exactly
retryAfter * 1000ms. - Retry the request, counting against
MAX_RETRIES.
if (res.status === RATE_LIMIT_STATUS) {
const retryAfter = this.readRetryAfterSeconds(res.headers);
attempts++;
if (attempts >= MAX_RETRIES) {
throw new Error(this.getErrorMessage(RATE_LIMIT_STATUS));
}
await delay(retryAfter * 1000);
continue;
}
Inter-Request Delay
To avoid triggering rate limits in the first place, sequential page fetches include a small jittered delay:
const REQUEST_DELAY = 100; // ms
const JITTER = 50; // ms
await delay(withJitter(REQUEST_DELAY));
This is applied only after page 1 and only when there were no consecutive errors on the current page.
Error Handling and Retries
Retry Budget
const MAX_RETRIES = 3;
Every API request is allowed up to 3 attempts in total (initial attempt + 2 retries).
Status Code Matrix
| Status | Retry? | Behaviour |
|---|---|---|
400 |
No | Immediate error: invalid request parameters. |
401 |
No | Immediate error: invalid or missing token. |
403 |
No | Immediate error: access denied (private channel or insufficient scope). |
404 |
No | Immediate error: channel/block not found. |
429 |
Yes | Respect Retry-After, wait, then retry. |
500, 502, 503, 504 |
Yes | Exponential backoff with jitter, then retry. |
| Network / transport error | Yes | Same backoff as 5xx. |
Unknown 4xx / 5xx |
No | Immediate error after exhausting retries. |
Backoff Formula
For transient errors (5xx and network failures):
function transientBackoffMs(attempts: number): number {
return Math.min(1000 * Math.pow(2, attempts), 10000)
+ Math.floor(Math.random() * JITTER);
}
| Attempt | Base delay | Max jitter | Max total |
|---|---|---|---|
| 1 | 2,000 ms | 50 ms | 2,050 ms |
| 2 | 4,000 ms | 50 ms | 4,050 ms |
| 3 | 8,000 ms | 50 ms | 8,050 ms |
| 4+ | capped at 10,000 ms | 50 ms | 10,050 ms |
For 429, the backoff is not exponential; it uses the server-provided Retry-After value directly.
Page-Level Retry Loop
Inside getAllChannelBlocksWithProgress, individual pages are fetched via fetchPageWithRetries, which maintains its own consecutive-error counter:
const MAX_CONSECUTIVE_ERRORS = 3;
If a single page fails repeatedly, the entire channel import aborts with a descriptive message:
Failed to fetch channel {slug} after 3 consecutive errors. Last error: …
This is distinct from the per-request MAX_RETRIES: a page may fail 3 times, be retried successfully, then later fail again; the consecutive counter resets on success.
Response Normalisation
Are.na has evolved its JSON schema over time. ArenaApi normalises three possible shapes into a single internal representation.
v3 Shape (current)
{ "data": [...], "meta": { "current_page": 1, "per_page": 100, "total_pages": 3, "total_count": 201 } }
v2 / Legacy Shape
{ "contents": [...], "length": 201, "total_pages": 3, "current_page": 1, "per": 100 }
Raw Array Fallback
[ /* … */ ]
normalizePaginatedResponse handles all three, preferring meta fields when present and falling back to top-level fields or array length. This future-proofs Tetromino against minor API schema drift without requiring a version bump.
Asset Downloads
Binary downloads (images, attachments) use a separate retry loop in downloadBinary():
- No auth header (URLs are public or pre-signed).
- Same
MAX_RETRIES = 3budget. - Same
429handling withRetry-After. - Same transient
5xxbackoff. - On terminal failure, throws an error prefixed with
Asset download …so the sync engine can record it as a per-block, recoverable error without aborting the entire import.
Caching
To reduce redundant API calls during a single import pass, ArenaApi keeps an in-memory LRU-style cache:
private readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
Cached entities:
- Channels: keyed by
channel:${slug} - Blocks: keyed by
block:${id}
Cache entries expire after 5 minutes. The cache is not persisted across Obsidian sessions; it exists only in the ArenaApi instance lifetime.
Future API Versions
Current Stance
Tetromino is pinned to API v3. The version prefix is a single constant:
const API_VERSION_PREFIX = "/v3";
Handling a Future v4 (or Breaking Change)
If Are.na releases a v4 with breaking changes, the recommended migration path is:
- Audit the diff: Compare v3 and v4 schemas for the endpoints Tetromino uses (
/channels,/blocks,/me). - Extend normalisation: Update
normalizePaginatedResponse,normalizeChannel, andnormalizeBlockto detect and adapt the new shape, preserving backward compatibility with v3 responses. - Bump the prefix: Change
API_VERSION_PREFIXto/v4once normalisation is proven in tests. - Add feature detection: If v4 introduces new capabilities Tetromino wants to use (e.g., richer metadata), gate them behind a runtime version check or a user opt-in setting.
- Communicate: Update
CHANGELOG.md,README.md, and this document. Notify users if the new API requires a different token scope.
Why Not Version Negotiation Today?
Are.na does not currently advertise an API version in responses, and the v3 surface is stable. Adding proactive version negotiation would introduce complexity without a known benefit. The normalisation layer already provides a lightweight form of forward compatibility.
Last updated: 2026-06-26