@pouchy_ai/admin-sdk 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,75 @@
2
2
 
3
3
  All notable changes to `@pouchy_ai/admin-sdk` are documented here.
4
4
 
5
+ ## 0.7.0 — 2026-07-27
6
+
7
+ Adds: a throttled request now tells you how long to wait.
8
+
9
+ - **`AdminApiError.retryAfter`** — seconds, present on a 429 and `undefined` on
10
+ every other failure. Every write this client makes (`POST` / `PATCH` /
11
+ `DELETE`) passes through one per-IP throttle on the server — a
12
+ `v1-admin-write` bucket of 120 non-GET requests per minute across all of
13
+ `/v1/*` — and the natural migration loop (`updateAgent` per agent,
14
+ `setUserSuspended` per user, `setSkillRate` per skill) reaches 120 in seconds.
15
+ The server has always answered with both a `Retry-After` header and a body
16
+ `retryAfter`; this package discarded both, so the one caller who provably
17
+ needs a backoff number had only the prose message to guess from — and a
18
+ guessed-too-short retry keeps the sliding window saturated, extending the
19
+ throttle it is trying to escape.
20
+
21
+ ```ts
22
+ try {
23
+ await admin.updateAgent(id, patch);
24
+ } catch (e) {
25
+ if (e instanceof AdminApiError && e.status === 429) {
26
+ await new Promise((r) => setTimeout(r, (e.retryAfter ?? 5) * 1000));
27
+ }
28
+ }
29
+ ```
30
+
31
+ Resolution order matches the companion JS SDK's `retryAfterFrom`: body
32
+ `retryAfterSec`, then body `retryAfter` (the key the `/v1` throttle actually
33
+ emits), then the `Retry-After` header as seconds, then its RFC 9110 HTTP-date
34
+ form converted to a non-negative delta. A response that names none leaves
35
+ `retryAfter` **undefined** — never `0`, which would tell backoff code to retry
36
+ immediately.
37
+
38
+ - **Back-compatible.** New optional third constructor argument and a new
39
+ optional readonly property; `status`, `message` and the `instanceof
40
+ AdminApiError` check are untouched. Minor bump because the public error type
41
+ gained a member.
42
+
43
+ Both sibling SDKs already surfaced this value (JS `CompanionError.retryAfter`,
44
+ python `CompanionError.retry_after`) — same name, same units, same precedence.
45
+
46
+ ## 0.6.1 — 2026-07-27
47
+
48
+ Fix: the four long-running requests are no longer cut at 30s while the server is
49
+ still working.
50
+
51
+ - **Per-route request deadlines.** `POST /knowledge`, `POST /knowledge/file`,
52
+ `POST /knowledge/url` and `DELETE /users/{instanceId}` are the Admin API
53
+ handlers that declare `maxDuration: 300` — their response headers only arrive
54
+ when the ingest (chunk + summarize + embed, after OCR / Whisper / vision or an
55
+ SSRF-guarded page fetch) or the recursive GDPR erasure has FINISHED. Under the
56
+ flat 30s deadline a real PDF or a data-heavy instance rejected
57
+ `request timed out after 30000ms` while the operation kept running and
58
+ billing server-side, and the natural retry then raced the still-running first
59
+ ingest. Those four now default to `LONG_WORK_TIMEOUT_MS` (310s — the server's
60
+ own ceiling plus headroom); every other request keeps the 30s default, so a
61
+ genuinely hung server still fails fast.
62
+
63
+ This is the same defect the companion JS SDK fixed as `BUFFERED_TURN_TIMEOUT_MS`
64
+ (CR-141); 0.6.0 added `/knowledge/file` and `/knowledge/url` behind the old
65
+ flat deadline without porting it.
66
+
67
+ - **`timeoutMs` still wins.** A host that sets `timeoutMs` explicitly gets it on
68
+ every request, long or short — only the DEFAULT is route-aware. No API surface
69
+ added or removed; purely widening for calls that previously failed.
70
+
71
+ - New exports for hosts that want the contract in code:
72
+ `LONG_WORK_TIMEOUT_MS`, `LONG_WORK_REQUESTS`, `requestDeadlineMs(method, path)`.
73
+
5
74
  ## 0.6.0 — 2026-07-21
6
75
 
7
76
  Additive: knowledge ingestion parity + headless agent version control. Both
package/README.md CHANGED
@@ -62,10 +62,24 @@ createAdminClient({
62
62
  adminKey: 'pchy_admin_…', // required
63
63
  baseUrl: 'https://pouchy.ai/v1/admin', // optional (self-host / staging)
64
64
  fetch: myFetch, // optional (Node <18, or tests)
65
- timeoutMs: 30_000 // optional per-request timeout (default 30s)
65
+ timeoutMs: 30_000 // optional per-request timeout see below
66
66
  });
67
67
  ```
68
68
 
69
+ `timeoutMs` defaults to **30s**, except for the four requests whose server
70
+ handler declares `maxDuration: 300` and therefore answers only when the work is
71
+ finished — `POST /knowledge`, `POST /knowledge/file`, `POST /knowledge/url`
72
+ (chunk + summarize + embed, after OCR / Whisper / vision or a page fetch) and
73
+ `DELETE /users/{instanceId}` (recursive GDPR erasure). Those default to **310s**
74
+ (`LONG_WORK_TIMEOUT_MS`), so a client abort can only ever mean "the server really
75
+ is hung", never "the server is still working" — a shorter deadline there reports
76
+ a failure for an ingest that is succeeding, and the retry it invites races the
77
+ still-running first one.
78
+
79
+ Setting `timeoutMs` explicitly always wins and applies to **every** request, long
80
+ or short. `requestDeadlineMs(method, path)` returns the default a given request
81
+ would use.
82
+
69
83
  ## Errors
70
84
 
71
85
  Every method throws `AdminApiError` on failure — a non-2xx response, a network
@@ -81,6 +95,33 @@ try {
81
95
  }
82
96
  ```
83
97
 
98
+ ### Throttling (429)
99
+
100
+ Writes are rate-limited per IP — 120 non-`GET` requests per minute across the
101
+ whole `/v1` plane — so a migration loop over agents, users or skills will hit it.
102
+ On a 429, `AdminApiError.retryAfter` carries the server's backoff in **seconds**
103
+ (from the body's `retryAfter` / `retryAfterSec`, or the `Retry-After` header).
104
+ It is `undefined` on every other failure, and `undefined` — never `0` — when the
105
+ server named no delay, so `??` your own floor rather than retrying immediately:
106
+
107
+ ```ts
108
+ async function withBackoff<T>(call: () => Promise<T>, tries = 5): Promise<T> {
109
+ for (let i = 0; ; i++) {
110
+ try {
111
+ return await call();
112
+ } catch (e) {
113
+ if (!(e instanceof AdminApiError) || e.status !== 429 || i >= tries) throw e;
114
+ await new Promise((r) => setTimeout(r, (e.retryAfter ?? 5) * 1000));
115
+ }
116
+ }
117
+ }
118
+
119
+ await withBackoff(() => admin.updateAgent(id, { status: 'published' }));
120
+ ```
121
+
122
+ Reads (`GET`) are not covered by that bucket. `retryAfter` is available from
123
+ 0.7.0.
124
+
84
125
  ## Surface
85
126
 
86
127
  | Area | Methods |
package/dist/index.d.ts CHANGED
@@ -1,5 +1,34 @@
1
- export declare const ADMIN_SDK_VERSION = "0.6.0";
1
+ export declare const ADMIN_SDK_VERSION = "0.7.0";
2
2
  export declare const DEFAULT_BASE_URL = "https://pouchy.ai/v1/admin";
3
+ /** Deadline for the routes whose server handler declares `maxDuration: 300` —
4
+ * the server's own ceiling plus headroom, so a client abort can only ever mean
5
+ * "the server really is hung", never "the server is still working".
6
+ *
7
+ * Why this exists: a flat client deadline BELOW a route's declared server
8
+ * ceiling turns a succeeding, billed operation into a client-side
9
+ * `request timed out`. The companion JS SDK hit exactly this (CR-141) and
10
+ * fixed it with `BUFFERED_TURN_TIMEOUT_MS = 310_000`; on knowledge ingest it
11
+ * is worse than a bad error message, because the integrator's natural retry
12
+ * then races the still-running first ingest — the register's CR-14 interleave,
13
+ * made systematic instead of rare. This SDK shipped the same flat 30s and, in
14
+ * 0.6.0, ADDED two more long routes behind it (`/knowledge/file`,
15
+ * `/knowledge/url`). Same number as the JS constant, for the same reason. */
16
+ export declare const LONG_WORK_TIMEOUT_MS = 310000;
17
+ /** The Admin API requests whose handler declares `maxDuration: 300`.
18
+ *
19
+ * Derived from the route files, not guessed — `sdk-deadlines.drift.test.ts`
20
+ * binds this list to `export const config = { maxDuration }` in
21
+ * `src/routes/v1/admin/**` in BOTH directions, so a new long-running admin
22
+ * route (or a renamed one) fails a merge instead of silently shipping a
23
+ * 30s client cut. Paths are SDK-relative (the base URL already carries
24
+ * `/v1/admin`). */
25
+ export declare const LONG_WORK_REQUESTS: ReadonlyArray<{
26
+ method: string;
27
+ path: RegExp;
28
+ }>;
29
+ /** The deadline a given request runs under when the host set no `timeoutMs`.
30
+ * Exported so the contract is assertable without timing anything. */
31
+ export declare function requestDeadlineMs(method: string, path: string): number;
3
32
  export interface AdminClientOptions {
4
33
  /** A project Admin key (`pchy_admin_…`) from the dashboard Admin Keys page. */
5
34
  adminKey: string;
@@ -7,15 +36,34 @@ export interface AdminClientOptions {
7
36
  baseUrl?: string;
8
37
  /** Inject a fetch impl (Node <18, or for tests). Default global fetch. */
9
38
  fetch?: typeof fetch;
10
- /** Per-request timeout in ms (default 30s). A request that outlives it
11
- * rejects with an AdminApiError(status 0). */
39
+ /** Per-request timeout in ms. Default 30s, EXCEPT the requests whose server
40
+ * handler declares `maxDuration: 300` (knowledge ingest by text/file/url,
41
+ * and the GDPR user delete), which default to
42
+ * {@link LONG_WORK_TIMEOUT_MS}. Setting this explicitly always wins and
43
+ * applies to every request, long or short. A request that outlives its
44
+ * deadline rejects with an AdminApiError(status 0). */
12
45
  timeoutMs?: number;
13
46
  }
14
47
  /** Thrown on any non-2xx response. `status` is the HTTP status; `message` is the
15
- * server's `error` string when present. */
48
+ * server's `error` string when present; `retryAfter` is the throttle's own
49
+ * backoff in SECONDS on a 429 (undefined on every other failure).
50
+ *
51
+ * Why `retryAfter` exists: the entire write surface of this SDK sits behind one
52
+ * per-IP throttle — `hooks.server.ts` runs a `v1-admin-write` bucket (120
53
+ * non-GET requests / minute) over every `/v1/*` path, and `/v1/admin` is where
54
+ * this client lives. A migration loop (`updateAgent` per agent,
55
+ * `setUserSuspended` per user, `setSkillRate` per skill) reaches 120 in
56
+ * seconds. The server answers with BOTH a `Retry-After` header and a body
57
+ * `retryAfter`, and this package used to discard both — leaving the one caller
58
+ * who provably needs a backoff number with nothing but a prose message to
59
+ * guess from, and a guessed-too-short retry keeps the sliding window
60
+ * saturated. Both sibling SDKs already surface it (JS
61
+ * `CompanionError.retryAfter`, python `CompanionError.retry_after`); this is
62
+ * the same value under the same name. */
16
63
  export declare class AdminApiError extends Error {
17
64
  status: number;
18
- constructor(message: string, status: number);
65
+ readonly retryAfter?: number;
66
+ constructor(message: string, status: number, retryAfter?: number);
19
67
  }
20
68
  export type Env = 'live' | 'test';
21
69
  export type AgentStatus = 'draft' | 'published';
package/dist/index.js CHANGED
@@ -8,19 +8,113 @@
8
8
  // import { createAdminClient } from '@pouchy_ai/admin-sdk';
9
9
  // const admin = createAdminClient({ adminKey: process.env.POUCHY_ADMIN_KEY! });
10
10
  // const { agents } = await admin.listAgents();
11
- export const ADMIN_SDK_VERSION = '0.6.0';
11
+ export const ADMIN_SDK_VERSION = '0.7.0';
12
12
  export const DEFAULT_BASE_URL = 'https://pouchy.ai/v1/admin';
13
13
  /** Default per-request timeout (ms). A hung upstream otherwise never rejects. */
14
14
  const DEFAULT_TIMEOUT_MS = 30_000;
15
+ /** Deadline for the routes whose server handler declares `maxDuration: 300` —
16
+ * the server's own ceiling plus headroom, so a client abort can only ever mean
17
+ * "the server really is hung", never "the server is still working".
18
+ *
19
+ * Why this exists: a flat client deadline BELOW a route's declared server
20
+ * ceiling turns a succeeding, billed operation into a client-side
21
+ * `request timed out`. The companion JS SDK hit exactly this (CR-141) and
22
+ * fixed it with `BUFFERED_TURN_TIMEOUT_MS = 310_000`; on knowledge ingest it
23
+ * is worse than a bad error message, because the integrator's natural retry
24
+ * then races the still-running first ingest — the register's CR-14 interleave,
25
+ * made systematic instead of rare. This SDK shipped the same flat 30s and, in
26
+ * 0.6.0, ADDED two more long routes behind it (`/knowledge/file`,
27
+ * `/knowledge/url`). Same number as the JS constant, for the same reason. */
28
+ export const LONG_WORK_TIMEOUT_MS = 310_000;
29
+ /** The Admin API requests whose handler declares `maxDuration: 300`.
30
+ *
31
+ * Derived from the route files, not guessed — `sdk-deadlines.drift.test.ts`
32
+ * binds this list to `export const config = { maxDuration }` in
33
+ * `src/routes/v1/admin/**` in BOTH directions, so a new long-running admin
34
+ * route (or a renamed one) fails a merge instead of silently shipping a
35
+ * 30s client cut. Paths are SDK-relative (the base URL already carries
36
+ * `/v1/admin`). */
37
+ export const LONG_WORK_REQUESTS = [
38
+ // Synchronous ingest: chunk + summarize + embed before headers.
39
+ { method: 'POST', path: /^\/knowledge$/ },
40
+ // …plus OCR / Whisper / vision understanding of the raw file first.
41
+ { method: 'POST', path: /^\/knowledge\/file$/ },
42
+ // …plus an SSRF-guarded fetch + HTML reduction first.
43
+ { method: 'POST', path: /^\/knowledge\/url$/ },
44
+ // GDPR erasure: recursive delete of the instance's whole users/** subtree
45
+ // plus the top-level social graph. A client abort here is the worst of the
46
+ // four — the operator is left not knowing whether the wipe completed.
47
+ { method: 'DELETE', path: /^\/users\/[^/]+$/ }
48
+ ];
49
+ /** The deadline a given request runs under when the host set no `timeoutMs`.
50
+ * Exported so the contract is assertable without timing anything. */
51
+ export function requestDeadlineMs(method, path) {
52
+ const bare = path.split('?')[0];
53
+ const m = method.toUpperCase();
54
+ return LONG_WORK_REQUESTS.some((r) => r.method === m && r.path.test(bare))
55
+ ? LONG_WORK_TIMEOUT_MS
56
+ : DEFAULT_TIMEOUT_MS;
57
+ }
15
58
  /** Thrown on any non-2xx response. `status` is the HTTP status; `message` is the
16
- * server's `error` string when present. */
59
+ * server's `error` string when present; `retryAfter` is the throttle's own
60
+ * backoff in SECONDS on a 429 (undefined on every other failure).
61
+ *
62
+ * Why `retryAfter` exists: the entire write surface of this SDK sits behind one
63
+ * per-IP throttle — `hooks.server.ts` runs a `v1-admin-write` bucket (120
64
+ * non-GET requests / minute) over every `/v1/*` path, and `/v1/admin` is where
65
+ * this client lives. A migration loop (`updateAgent` per agent,
66
+ * `setUserSuspended` per user, `setSkillRate` per skill) reaches 120 in
67
+ * seconds. The server answers with BOTH a `Retry-After` header and a body
68
+ * `retryAfter`, and this package used to discard both — leaving the one caller
69
+ * who provably needs a backoff number with nothing but a prose message to
70
+ * guess from, and a guessed-too-short retry keeps the sliding window
71
+ * saturated. Both sibling SDKs already surface it (JS
72
+ * `CompanionError.retryAfter`, python `CompanionError.retry_after`); this is
73
+ * the same value under the same name. */
17
74
  export class AdminApiError extends Error {
18
75
  status;
19
- constructor(message, status) {
76
+ retryAfter;
77
+ constructor(message, status, retryAfter) {
20
78
  super(message);
21
79
  this.name = 'AdminApiError';
22
80
  this.status = status;
81
+ if (retryAfter !== undefined)
82
+ this.retryAfter = retryAfter;
83
+ }
84
+ }
85
+ /** Seconds to wait before retrying a throttled response, or undefined when the
86
+ * server named neither a body field nor a header.
87
+ *
88
+ * Precedence mirrors the JS companion SDK's `retryAfterFrom`, plus the one key
89
+ * this plane actually emits: the `/v1` admin-write 429 body says `retryAfter`,
90
+ * while the `/api/companion` 429 body says `retryAfterSec`. Accept both so the
91
+ * same helper is correct if a route is ever aligned on the other name.
92
+ *
93
+ * A missing/blank header must stay undefined — `Number(null)` is 0, which
94
+ * would stamp `retryAfter: 0` on EVERY error without the header (a 404, a
95
+ * 400 …) and tell backoff code "retry immediately". */
96
+ function retryAfterFrom(res, body) {
97
+ for (const k of ['retryAfterSec', 'retryAfter']) {
98
+ const v = body?.[k];
99
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0)
100
+ return v;
23
101
  }
102
+ // Optional-chained because `opts.fetch` is a documented injection point and
103
+ // hand-rolled test doubles routinely return a bare `{ ok, status, json }`
104
+ // with no `headers` — reading a header must never turn their 4xx fixture
105
+ // into a TypeError.
106
+ const raw = res.headers?.get?.('Retry-After');
107
+ if (raw === null || raw === undefined || raw.trim() === '')
108
+ return undefined;
109
+ const header = Number(raw);
110
+ if (Number.isFinite(header) && header >= 0)
111
+ return header;
112
+ // RFC 9110 also allows an HTTP-date (proxies/CDNs emit it) — convert to a
113
+ // non-negative seconds delta. Unparseable stays undefined.
114
+ const at = Date.parse(raw);
115
+ if (!Number.isNaN(at))
116
+ return Math.max(0, Math.ceil((at - Date.now()) / 1000));
117
+ return undefined;
24
118
  }
25
119
  function qs(params) {
26
120
  const u = new URLSearchParams();
@@ -37,8 +131,11 @@ export function createAdminClient(opts) {
37
131
  const f = opts.fetch ?? globalThis.fetch;
38
132
  if (!f)
39
133
  throw new AdminApiError('no fetch available — pass opts.fetch on Node <18', 0);
40
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
41
134
  async function request(method, path, body) {
135
+ // An explicit host `timeoutMs` always wins (JS SDK contract); otherwise
136
+ // the deadline is sized to the ROUTE, so the four handlers that declare
137
+ // `maxDuration: 300` are not cut at 30s while they are still working.
138
+ const timeoutMs = opts.timeoutMs ?? requestDeadlineMs(method, path);
42
139
  // Every failure surfaces as an AdminApiError (the doc contract): a
43
140
  // network/DNS error or a timeout would otherwise escape as a raw
44
141
  // `TypeError: fetch failed` / AbortError. Status 0 = never reached the
@@ -61,7 +158,7 @@ export function createAdminClient(opts) {
61
158
  }
62
159
  const data = (await res.json().catch(() => ({})));
63
160
  if (!res.ok)
64
- throw new AdminApiError(data?.error ?? `HTTP ${res.status}`, res.status);
161
+ throw new AdminApiError(data?.error ?? `HTTP ${res.status}`, res.status, retryAfterFrom(res, data));
65
162
  return data;
66
163
  }
67
164
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pouchy_ai/admin-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Typed TypeScript client for the Pouchy Admin API \u2014 manage agents, keys, end users, knowledge, skills, channels, schedules, webhooks and credentials headlessly, with a project Admin key.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",