@sudosapient/client 0.1.0 → 0.2.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/README.md +12 -3
- package/index.js +78 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,14 +17,23 @@ try {
|
|
|
17
17
|
const me = await api.get('/me');
|
|
18
18
|
const mine = await api.all('/tasks', { assignee: 'me', status: 'open' });
|
|
19
19
|
await api.post('/tasks', { project: 'WEB', title: 'Fix login bug' });
|
|
20
|
+
await api.upload('/files', { task: 'WEB-212', name: 'trace.txt' }, new TextEncoder().encode('…'), 'text/plain');
|
|
20
21
|
} catch (e) {
|
|
21
22
|
if (e instanceof ApiError) console.error(e.status, e.code, e.message);
|
|
22
23
|
}
|
|
23
24
|
```
|
|
24
25
|
|
|
25
|
-
- `createClient({ url, key, client, fetch })`: `get`, `post`, `patch`, `
|
|
26
|
-
page of a list endpoint)
|
|
27
|
-
|
|
26
|
+
- `createClient({ url, key, client, fetch, retry, onRetry })`: `get`, `post`, `patch`, `put`, `del`,
|
|
27
|
+
`request`, `all` (every page of a list endpoint), `upload(path, query, bytes, type)` (raw bytes as
|
|
28
|
+
the body, for `POST /files`) and `download(path)` (`{ bytes, type, name }`). `client` labels your changes in Activity ("Ada via My
|
|
29
|
+
script"). Errors are `ApiError` with `status`, `code` and `field` (and `retryAfter`, in seconds, on
|
|
30
|
+
a `429`).
|
|
31
|
+
- Rate limits are waited out: a `429` with `Retry-After` is sent again after that many seconds, up
|
|
32
|
+
to `retry.retries` times (default 3), as long as each wait is at most `retry.maxDelay` ms (default
|
|
33
|
+
60 000) and the waits for one request add up to at most `retry.maxTotal` ms (default 120 000);
|
|
34
|
+
otherwise the `429` is thrown straight away. A `Retry-After` of 0 still waits a second. The server refuses such a request before handling
|
|
35
|
+
it, so this is safe for writes too. `onRetry({ attempt, delay, method, path })` is called before
|
|
36
|
+
each wait; `retry: { retries: 0 }` turns it off.
|
|
28
37
|
- `plainHttpRemote(url)`: true when the URL would send the key unencrypted to another machine.
|
|
29
38
|
- `@sudosapient/client/config`: `loadConfig`, `saveConfig`, `clearConfig` and `configPath` for
|
|
30
39
|
`$XDG_CONFIG_HOME/sudo-sapient/config.json`, the file `sudo-cli login` writes (readable only by
|
package/index.js
CHANGED
|
@@ -2,46 +2,91 @@
|
|
|
2
2
|
No dependencies: it runs on any fetch (Node 22+, or an in-process `app.request`). */
|
|
3
3
|
|
|
4
4
|
export class ApiError extends Error {
|
|
5
|
-
constructor(status, body) {
|
|
5
|
+
constructor(status, body, retryAfter = null) {
|
|
6
6
|
super(body?.error || (status ? `Request failed (${status})` : 'Couldn’t reach Sudo Sapient. Check the URL and your connection.'));
|
|
7
7
|
this.status = status;
|
|
8
8
|
this.code = body?.code || (status ? 'http_' + status : 'offline');
|
|
9
9
|
this.field = body?.field || null;
|
|
10
|
+
this.retryAfter = retryAfter;
|
|
10
11
|
}
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/* Rate limits (429). The API refuses a request over the limit before handling it (middleware.js
|
|
15
|
+
`bearer`), so nothing of it was applied and retrying is safe for every method, writes included.
|
|
16
|
+
The client waits as long as Retry-After says (at least a second), up to `retries` times, never
|
|
17
|
+
longer than `maxDelay` at once nor `maxTotal` in all for one request: a wait past either is
|
|
18
|
+
reported instead (an ApiError with `retryAfter`, in seconds). A 429 without Retry-After (e.g.
|
|
19
|
+
"too many invites this hour") isn't retried. */
|
|
20
|
+
export const RETRY = { retries: 3, maxDelay: 60_000, maxTotal: 120_000 };
|
|
21
|
+
const MIN_WAIT = 1000;
|
|
22
|
+
|
|
23
|
+
/* Milliseconds to wait from a Retry-After header (seconds, or an HTTP date), or null. */
|
|
24
|
+
export function retryAfterMs(value, now = Date.now()) {
|
|
25
|
+
const v = String(value ?? '').trim();
|
|
26
|
+
if (!v) return null;
|
|
27
|
+
if (/^\d+$/.test(v)) return Number(v) * 1000;
|
|
28
|
+
const at = Date.parse(v);
|
|
29
|
+
return Number.isFinite(at) ? Math.max(0, at - now) : null;
|
|
30
|
+
}
|
|
31
|
+
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
32
|
+
|
|
13
33
|
/* `url`: the workspace's base URL (https://sudo.example.com). `client`: a label for activity, e.g.
|
|
14
|
-
"CLI" or "Claude", shown as "Ada via Claude".
|
|
15
|
-
|
|
34
|
+
"CLI" or "Claude", shown as "Ada via Claude". `retry`: { retries, maxDelay, maxTotal } for rate limits (see
|
|
35
|
+
RETRY; `{ retries: 0 }` turns retrying off). `onRetry({ attempt, delay, method, path })` is called
|
|
36
|
+
before each wait, e.g. to tell someone at a terminal why it paused. */
|
|
37
|
+
export function createClient({ url, key, client = 'API', fetch: doFetch = globalThis.fetch, retry = {}, onRetry, sleep = wait }) {
|
|
16
38
|
const base = String(url || '').replace(/\/+$/, '') + '/api/v1';
|
|
17
|
-
|
|
39
|
+
const { retries, maxDelay, maxTotal } = { ...RETRY, ...retry };
|
|
40
|
+
/* `raw`: { body, type } sent as is instead of JSON (a file upload). `binary`: answer with the bytes
|
|
41
|
+
({ bytes, type, name }) instead of parsed JSON (a file download). */
|
|
42
|
+
async function request(method, path, { query, body, idempotencyKey, raw, binary = false } = {}) {
|
|
18
43
|
const qs = query ? new URLSearchParams(Object.entries(query).filter(([, v]) => v != null && v !== '')).toString() : '';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
const init = {
|
|
45
|
+
method,
|
|
46
|
+
headers: {
|
|
47
|
+
authorization: `Bearer ${key}`,
|
|
48
|
+
// URI-encoded: header values must be Latin-1, and client names needn't be.
|
|
49
|
+
'x-sudo-client': encodeURIComponent(client),
|
|
50
|
+
accept: 'application/json',
|
|
51
|
+
...(raw ? { 'content-type': raw.type || 'application/octet-stream' } : body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
52
|
+
...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
|
|
53
|
+
},
|
|
54
|
+
body: raw ? raw.body : body !== undefined ? JSON.stringify(body) : undefined,
|
|
55
|
+
};
|
|
56
|
+
let waited = 0;
|
|
57
|
+
for (let attempt = 1; ; attempt++) {
|
|
58
|
+
let res;
|
|
59
|
+
try {
|
|
60
|
+
res = await doFetch(base + path + (qs ? `?${qs}` : ''), init);
|
|
61
|
+
} catch {
|
|
62
|
+
throw new ApiError(0, null);
|
|
63
|
+
}
|
|
64
|
+
if (res.ok && binary) return { bytes: new Uint8Array(await res.arrayBuffer()), type: res.headers.get('content-type') || '', name: fileName(res) };
|
|
65
|
+
const data = (res.headers.get('content-type') || '').includes('json') ? await res.json().catch(() => null) : null;
|
|
66
|
+
if (res.ok) return data;
|
|
67
|
+
const header = res.status === 429 ? retryAfterMs(res.headers.get('retry-after')) : null;
|
|
68
|
+
// "Retry-After: 0" still waits a moment, so a client can't spin against the limit.
|
|
69
|
+
const after = header == null ? null : Math.max(header, MIN_WAIT);
|
|
70
|
+
if (after == null || after > maxDelay || waited + after > maxTotal || attempt > retries)
|
|
71
|
+
throw new ApiError(res.status, data, header == null ? null : Math.ceil(header / 1000));
|
|
72
|
+
// A little jitter, so clients that were refused together don't all come back in the same instant.
|
|
73
|
+
const delay = after + Math.floor(Math.random() * 250);
|
|
74
|
+
waited += delay;
|
|
75
|
+
onRetry?.({ attempt, delay, method, path });
|
|
76
|
+
await sleep(delay);
|
|
35
77
|
}
|
|
36
|
-
const data = (res.headers.get('content-type') || '').includes('json') ? await res.json().catch(() => null) : null;
|
|
37
|
-
if (!res.ok) throw new ApiError(res.status, data);
|
|
38
|
-
return data;
|
|
39
78
|
}
|
|
40
79
|
return {
|
|
41
80
|
request,
|
|
42
81
|
get: (path, query) => request('GET', path, { query }),
|
|
43
82
|
post: (path, body = {}, opts) => request('POST', path, { body, ...opts }),
|
|
44
83
|
patch: (path, body, opts) => request('PATCH', path, { body, ...opts }),
|
|
84
|
+
put: (path, body = {}, opts) => request('PUT', path, { body, ...opts }),
|
|
85
|
+
del: (path, opts) => request('DELETE', path, opts),
|
|
86
|
+
/* POST raw bytes, e.g. upload('/files', { project: 'WEB', name: 'spec.pdf' }, bytes, 'application/pdf'). */
|
|
87
|
+
upload: (path, query, bytes, type, opts) => request('POST', path, { query, raw: { body: bytes, type }, ...opts }),
|
|
88
|
+
/* GET bytes: { bytes, type, name }. */
|
|
89
|
+
download: (path, query) => request('GET', path, { query, binary: true }),
|
|
45
90
|
/* Every page of a list endpoint, up to `max` items. */
|
|
46
91
|
async all(path, query = {}, max = 500) {
|
|
47
92
|
const out = [];
|
|
@@ -56,6 +101,16 @@ export function createClient({ url, key, client = 'API', fetch: doFetch = global
|
|
|
56
101
|
};
|
|
57
102
|
}
|
|
58
103
|
|
|
104
|
+
/* The file name a download was sent with (Content-Disposition: filename*=UTF-8''…). */
|
|
105
|
+
function fileName(res) {
|
|
106
|
+
const m = /filename\*=UTF-8''([^;]+)/i.exec(res.headers.get('content-disposition') || '');
|
|
107
|
+
try {
|
|
108
|
+
return m ? decodeURIComponent(m[1]) : null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
59
114
|
/* Path segment for a task or project reference (an id or key). */
|
|
60
115
|
export const seg = ref => encodeURIComponent(String(ref).trim());
|
|
61
116
|
|