Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/tidy-pandas-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@thatopen/services': minor
---

Rate limit guidance and a backoff-aware retry policy.

- `docs/rate-limits.md` documents the per-endpoint limits, the `429` body, and the local-draft
save pattern (keep work in progress in `localStorage` / IndexedDB, write on an explicit save).
- `resources/AGENTS.md` gains a hard rule against autosaving to the platform on every change,
so assistants stop building write-per-keystroke loops.
- `RequestError.retryAfter` exposes the wait in seconds, read from `Retry-After` or
`details.retryAfter`.
- Retries now back off exponentially with jitter and honour `Retry-After`. Only network
failures, `429` and `5xx` are retried — other `4xx` fail immediately instead of being
repeated. Retries remain off by default.
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ Examples of comment-worthy behavior:

Local `.thatopen` (project root) takes priority over global `~/.thatopen/config.json`. The `resolveConfig()` helper in `src/cli/lib/config.ts` handles this — use it, don't re-implement.

## Rate limits

`docs/rate-limits.md` is the user-facing page: per-endpoint limits, the `429` shape, and the
local-draft save pattern. The numbers are **copied from the backend**, so they go stale silently
— the source of truth is the `@Throttle` decorators in `platform_backend-api`
(`src/api/**/**.controller.ts`) plus the `ThrottlerModule.forRoot` default in `src/app.module.ts`.
Re-check them whenever a limit changes, and keep the hard rule at the top of `resources/AGENTS.md`
in sync with them.

Client-side: `EngineServicesClient` retries only network failures, `429` and `5xx`, with
exponential backoff plus jitter, honouring `Retry-After`. `RequestError.retryAfter` carries the
wait in seconds (header first, then `details.retryAfter`). Retries stay off by default (`retries: 0`).

## Backend permissions contract

When a request includes a `projectId`, the backend validates that the resource belongs to that project and the caller has permission there — regardless of access in other projects. This enforcement is server-side and invisible in the client code.
Expand Down
5 changes: 5 additions & 0 deletions docs/ai-quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,9 @@ component, once added to a project, is then triggered by an app or an automation
four-point plan and waiting for approval to do what was just requested costs the user a whole turn
and buys nothing. Stop and ask only when you are about to change files you did not create, when
the request can be read two ways that mean different work, or when the next step is destructive.
- **Never save to the platform on every change.** Drafts belong in `localStorage` /
IndexedDB; the platform gets an explicit save. Writes are capped at 30 per minute and a
`429` loses the write. See
`node_modules/@thatopen/services/docs/rate-limits.md` before you write any save, sync or
polling code.
- The scaffold already works — **extend it, don't replace it.**
123 changes: 123 additions & 0 deletions docs/rate-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Rate limits, and how to save data without hitting them

The platform API is rate limited. Every app and cloud component shares the same
budget, so **how often you write decides whether your app works**. This page has
the numbers, the failure mode, and the pattern to use instead.

> **The one rule:** never send a request on every change the user makes. No
> autosave-per-keystroke, no write inside a render loop, no polling loop that
> hammers a list endpoint. Keep work-in-progress in the browser and talk to the
> server on an explicit save.

---

## The numbers

Limits are per **rolling 60-second window**, counted per user (JWT), or per API
token owner, or per IP — whichever identifies the caller.

| Endpoint | Client method | Limit |
|---|---|---|
| `POST /api/item` | `createFile`, `createComponent`, `createApp` | **30 / min** |
| `POST /api/item/:id/version` | `updateFile` / `updateComponent` **with a `file`** | **30 / min** |
| `PUT /api/item/:id/version/:tag/metadata` | `updateFileVersionMetadata` | 30 / min |
| `DELETE /api/item/:id/version/:tag/metadata` | `deleteFileVersionMetadata` | 30 / min |
| `PUT /api/item/:id/version/:tag/archive` \| `/recover` | `archiveVersion`, `recoverVersion` | 30 / min |
| `POST /api/item/hidden` \| `/hidden/batch` | `createHiddenFile`, `createHiddenFilesBatch` | 30 / min |
| `POST /api/processor/:id/execute` | `executeComponent` | 20 / min |
| `GET /api/item/folder/:id/download`, `POST /api/item/batch/download` | `downloadFolder` | 10 / min |
| `POST /api/item/batch/versions` \| `/batch/version-metadata` \| `/batch/folders` | `listVersionsBatch`, `getFileVersionMetadataBatch`, `getFoldersBatch` | 60 / min |
| `POST /api/item/hidden/signed-url/batch` | `getHiddenFileSignedUrlsBatch` | 100 / min |
| `GET /api/item/hidden/:id/download` | `downloadHiddenFile` | 3000 / min |
| Everything else | — | 100 / min |

Two things follow from the table:

- **30 writes per minute is one write every two seconds.** An autosave tied to
user input passes that in a few seconds of typing or dragging.
- **Reads are cheap, but not free.** A viewer that mints one signed URL per tile
will exhaust 100/min quickly — use `getHiddenFileSignedUrlsBatch`, which signs
up to `STORAGE_BATCH_MAX` files per request.

## What a rate-limited response looks like

Status `429`, with a `Retry-After` header (seconds) and this body:

```json
{
"statusCode": 429,
"message": "Rate limit exceeded: max 30 requests per 60s for this endpoint. Retry after 12s.",
"code": "RATE_LIMITED",
"details": { "limit": 30, "windowSeconds": 60, "retryAfter": 12, "scope": "user" }
}
```

The client surfaces it as a `RequestError`:

```ts
import { RequestError } from '@thatopen/services';

try {
await client.updateFile(fileId, { file: blob, versionTag: tag });
} catch (err) {
if (err instanceof RequestError && err.status === 429) {
// err.code === 'RATE_LIMITED'
// err.retryAfter — seconds to wait, from Retry-After or details.retryAfter
showToast(`Saving is paused for ${err.retryAfter}s — your work is kept locally.`);
return;
}
throw err;
}
```

**A 429 means the write did not happen.** Nothing was saved. If the user's only
copy of the change was in that request, it is gone — which is the real reason
the local-draft pattern below matters.

## The pattern: local drafts, explicit saves

Keep every intermediate state in the browser. Write to the platform only when
the user asks for it.

```ts
const draftKey = `draft:${fileId}`;

function onChange(state: unknown) {
localStorage.setItem(draftKey, JSON.stringify({ state, at: Date.now() }));
}

async function onSave(state: unknown) {
const blob = new Blob([JSON.stringify(state)], { type: 'application/json' });
await client.updateFile(fileId, { file: blob, versionTag: `v${Date.now()}` });
localStorage.removeItem(draftKey);
}
```

On load, if a draft exists for the file, offer to restore it. That gives crash
recovery — the thing autosave was really for — at zero requests.

Rules of thumb:

- **Explicit save**, or a timer no faster than **once every 30 seconds**, and
only when something actually changed.
- **One request per save**, not one per changed object. Batch the whole document.
- **Never save while a save is in flight.** Keep a flag per file, and drop or
queue the second save. Overlapping writes also create versions that are hard
to reconcile.
- **Big or binary work-in-progress** belongs in IndexedDB, not `localStorage`
(about 5 MB per origin).
- There is **no draft-write method in the client on purpose.** Local storage is
the draft store; the platform stores versions the user chose to keep.

## Retries

The client does not retry by default. When you turn retries on, it backs off
exponentially, adds jitter, and honours `Retry-After`:

```ts
const client = new EngineServicesClient(token, apiUrl, { retries: 3 });
```

Only network failures, `429` and `5xx` are retried. Other `4xx` fail straight
away, because repeating them cannot help. Never write your own immediate retry
loop around a 429 — that is what turns a throttled request into an outage.
12 changes: 11 additions & 1 deletion resources/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Do this before answering any question or writing any code. These are compact des
| Platform built-ins | `node_modules/@thatopen/services/docs/builtin/paths.json` |
| Platform client API | `node_modules/@thatopen/services/docs/client/paths.json` |
| CLI commands | `node_modules/@thatopen/services/docs/cli/paths.json` |
| Rate limits + how to save data | `node_modules/@thatopen/services/docs/rate-limits.md` |
| Engine components (`OBC`, `OBF`) | `https://raw.githubusercontent.com/ThatOpen/engine_components/refs/heads/main/examples/paths.json` |
| Fragments (`FRAGS`) | `https://raw.githubusercontent.com/ThatOpen/engine_fragment/refs/heads/main/examples/paths.json` |
| UI components (`BUI`) | `https://raw.githubusercontent.com/ThatOpen/engine_ui-components/refs/heads/main/examples/paths.json` — **skip** entries whose path contains `packages/obc` or `bim-grid` |
Expand All @@ -38,4 +39,13 @@ Once you have these, you know everything available on the platform. Only then fe

## Hard rules (always apply)

1. **All UI must be built with Lit**, using the web components from `@thatopen/ui` (`BUI`) — `bim-button`, `bim-panel`, `bim-panel-section`, `bim-toolbar`, `bim-dropdown`, `bim-input`, and the rest of `packages/core`. Always consult the design system before writing any UI: `https://raw.githubusercontent.com/ThatOpen/engine_ui-components/refs/heads/main/DESIGN.md`.
1. **Never write to the platform on every change.** No autosave per keystroke,
per drag, or inside a render loop. Keep work in progress in `localStorage` /
IndexedDB and call the platform on an **explicit user save** (or a timer no
faster than once every 30 seconds). Writes are capped at **30 per minute**;
crossing that returns `429` and the write is **lost**, not queued.
If the user asks for autosave, build it against local storage and say so.
Read `node_modules/@thatopen/services/docs/rate-limits.md` before writing any
save, sync, or polling code — it has the per-endpoint limits and the pattern.

2. **All UI must be built with Lit**, using the web components from `@thatopen/ui` (`BUI`) — `bim-button`, `bim-panel`, `bim-panel-section`, `bim-toolbar`, `bim-dropdown`, `bim-input`, and the rest of `packages/core`. Always consult the design system before writing any UI: `https://raw.githubusercontent.com/ThatOpen/engine_ui-components/refs/heads/main/DESIGN.md`.
136 changes: 136 additions & 0 deletions src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ function errorResponse(status: number, message = 'Bad Request'): Response {
} as unknown as Response;
}

function throttledResponse(retryAfter?: string): Response {
const body = JSON.stringify({
message: 'Rate limit exceeded: max 30 requests per 60s for this endpoint.',
code: 'RATE_LIMITED',
details: { limit: 30, windowSeconds: 60, retryAfter: 12, scope: 'user' },
});
return {
ok: false,
status: 429,
statusText: 'Too Many Requests',
headers: { get: (name: string) => (name === 'Retry-After' ? retryAfter ?? null : null) },
text: async () => body,
json: async () => JSON.parse(body),
} as unknown as Response;
}

function getCall(
fetchMock: Mock,
index = 0,
Expand Down Expand Up @@ -574,3 +590,123 @@ describe('EngineServicesClient — HTTP contract', () => {
});
});
});

describe('EngineServicesClient — retry policy', () => {
let fetchMock: Mock;

beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

async function runWithTimers<T>(promise: Promise<T>): Promise<T> {
const settled = promise.then(
(value) => ({ ok: true as const, value }),
(error: unknown) => ({ ok: false as const, error }),
);
await vi.runAllTimersAsync();
const result = await settled;
if (!result.ok) throw result.error;
return result.value;
}

it('does not retry a 4xx that is not a rate limit', async () => {
fetchMock.mockResolvedValue(errorResponse(404, 'Not Found'));
const client = new EngineServicesClient(TOKEN, API, { retries: 3 });

await expect(runWithTimers(client.listFiles())).rejects.toMatchObject({
status: 404,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('retries a 429 and succeeds on the next attempt', async () => {
fetchMock
.mockResolvedValueOnce(throttledResponse('2'))
.mockResolvedValueOnce(okResponse([{ _id: 'file-1' }]));
const client = new EngineServicesClient(TOKEN, API, { retries: 2 });

const files = await runWithTimers(client.listFiles());

expect(files).toEqual([{ _id: 'file-1' }]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('waits for the Retry-After window before retrying a 429', async () => {
fetchMock
.mockResolvedValueOnce(throttledResponse('2'))
.mockResolvedValueOnce(okResponse([]));
const client = new EngineServicesClient(TOKEN, API, { retries: 1 });

const pending = client.listFiles();
const settled = pending.then(() => 'done');

await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);

await vi.advanceTimersByTimeAsync(1500);
expect(fetchMock).toHaveBeenCalledTimes(1);

await vi.advanceTimersByTimeAsync(3000);
expect(fetchMock).toHaveBeenCalledTimes(2);
await expect(settled).resolves.toBe('done');
});

it('gives up after the configured number of retries', async () => {
fetchMock.mockResolvedValue(throttledResponse('1'));
const client = new EngineServicesClient(TOKEN, API, { retries: 2 });

await expect(runWithTimers(client.listFiles())).rejects.toMatchObject({
status: 429,
});
expect(fetchMock).toHaveBeenCalledTimes(3);
});

it('retries server errors and network failures', async () => {
fetchMock
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockResolvedValueOnce(errorResponse(503, 'Service Unavailable'))
.mockResolvedValueOnce(okResponse([]));
const client = new EngineServicesClient(TOKEN, API, { retries: 3 });

await expect(runWithTimers(client.listFiles())).resolves.toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(3);
});

it('does not retry when retries are left at the default of 0', async () => {
fetchMock.mockResolvedValue(throttledResponse('1'));
const client = new EngineServicesClient(TOKEN, API);

await expect(runWithTimers(client.listFiles())).rejects.toMatchObject({
status: 429,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('exposes retryAfter, code and details from a throttled response', async () => {
fetchMock.mockResolvedValue(throttledResponse('7'));
const client = new EngineServicesClient(TOKEN, API);

await expect(runWithTimers(client.listFiles())).rejects.toMatchObject({
status: 429,
code: 'RATE_LIMITED',
retryAfter: 7,
details: { limit: 30, windowSeconds: 60, retryAfter: 12, scope: 'user' },
});
});

it('falls back to details.retryAfter when the header is missing', async () => {
fetchMock.mockResolvedValue(throttledResponse(undefined));
const client = new EngineServicesClient(TOKEN, API);

await expect(runWithTimers(client.listFiles())).rejects.toMatchObject({
retryAfter: 12,
});
});
});
Loading
Loading