@vertesia/client 1.5.0-dev.20260910.052912Z → 1.5.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/lib/store/ObjectsApi.d.ts.map +1 -1
- package/lib/store/ObjectsApi.js +4 -3
- package/lib/store/ObjectsApi.js.map +1 -1
- package/lib/store/signed-url.d.ts +3 -2
- package/lib/store/signed-url.d.ts.map +1 -1
- package/lib/store/signed-url.js +8 -2
- package/lib/store/signed-url.js.map +1 -1
- package/lib/vertesia-client.js +1 -1
- package/lib/vertesia-client.js.map +1 -1
- package/package.json +5 -5
- package/src/store/ObjectsApi.test.ts +50 -0
- package/src/store/ObjectsApi.ts +5 -3
- package/src/store/signed-url.test.ts +24 -2
- package/src/store/signed-url.ts +8 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vertesia/client",
|
|
3
|
-
"version": "1.5.0
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"types": "./lib/index.d.ts",
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"eventsource": "^5.0.0",
|
|
22
|
-
"@
|
|
23
|
-
"@vertesia/
|
|
24
|
-
"@
|
|
22
|
+
"@llumiverse/common": "1.5.0",
|
|
23
|
+
"@vertesia/api-fetch-client": "1.5.0",
|
|
24
|
+
"@vertesia/common": "1.5.0"
|
|
25
25
|
},
|
|
26
26
|
"exports": {
|
|
27
27
|
".": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"ai",
|
|
49
49
|
"typescript"
|
|
50
50
|
],
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "3485742020066ab1fd3292487cf3a9577b5e4d91",
|
|
52
52
|
"scripts": {
|
|
53
53
|
"lint": "biome lint src",
|
|
54
54
|
"clean:lib": "rimraf ./lib ./tsconfig.tsbuildinfo",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ZenoClient } from './client.js';
|
|
3
|
+
|
|
4
|
+
describe('ObjectsApi revision labels', () => {
|
|
5
|
+
it.each(['v1.2', 'Review — 日本語', 'Révision – café', '100% complete & reviewed + approved'])(
|
|
6
|
+
'preserves the revision label %s through the query contract',
|
|
7
|
+
async (revisionLabel) => {
|
|
8
|
+
const requests: Request[] = [];
|
|
9
|
+
const client = new ZenoClient({
|
|
10
|
+
serverUrl: 'https://api.example.com',
|
|
11
|
+
fetch: vi.fn(async () => Response.json({ id: 'document-id' })),
|
|
12
|
+
onRequest: (request) => requests.push(request),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
await client.objects.update(
|
|
16
|
+
'document-id',
|
|
17
|
+
{ name: 'Updated document' },
|
|
18
|
+
{
|
|
19
|
+
createRevision: true,
|
|
20
|
+
revisionLabel,
|
|
21
|
+
ifMatch: 'previous-etag',
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(requests).toHaveLength(1);
|
|
26
|
+
const request = requests[0];
|
|
27
|
+
expect(request.method).toBe('PUT');
|
|
28
|
+
expect(new URL(request.url).searchParams.get('revision_label')).toBe(revisionLabel);
|
|
29
|
+
expect(request.headers.has('x-revision-label')).toBe(false);
|
|
30
|
+
expect(request.headers.get('x-create-revision')).toBe('true');
|
|
31
|
+
expect(request.headers.get('if-match')).toBe('previous-etag');
|
|
32
|
+
expect(await request.clone().json()).toEqual({ name: 'Updated document' });
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
it('does not send a label when updating in place', async () => {
|
|
37
|
+
const requests: Request[] = [];
|
|
38
|
+
const client = new ZenoClient({
|
|
39
|
+
serverUrl: 'https://api.example.com',
|
|
40
|
+
fetch: vi.fn(async () => Response.json({ id: 'document-id' })),
|
|
41
|
+
onRequest: (request) => requests.push(request),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await client.objects.update('document-id', {}, { revisionLabel: 'Unused — label' });
|
|
45
|
+
|
|
46
|
+
expect(new URL(requests[0].url).searchParams.has('revision_label')).toBe(false);
|
|
47
|
+
expect(requests[0].headers.has('x-create-revision')).toBe(false);
|
|
48
|
+
expect(requests[0].headers.has('x-revision-label')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
package/src/store/ObjectsApi.ts
CHANGED
|
@@ -437,9 +437,6 @@ export class ObjectsApi extends ApiTopic {
|
|
|
437
437
|
}
|
|
438
438
|
if (options?.createRevision) {
|
|
439
439
|
headers[ContentObjectApiHeaders.CREATE_REVISION] = 'true';
|
|
440
|
-
if (options.revisionLabel) {
|
|
441
|
-
headers[ContentObjectApiHeaders.REVISION_LABEL] = options.revisionLabel;
|
|
442
|
-
}
|
|
443
440
|
}
|
|
444
441
|
if (options?.suppressWorkflows) {
|
|
445
442
|
headers[ContentObjectApiHeaders.SUPPRESS_WORKFLOWS] = 'true';
|
|
@@ -447,6 +444,11 @@ export class ObjectsApi extends ApiTopic {
|
|
|
447
444
|
|
|
448
445
|
return this.put(`/${id}`, {
|
|
449
446
|
payload: updatePayload,
|
|
447
|
+
// The existing query contract preserves Unicode labels; HTTP headers only accept ByteString values.
|
|
448
|
+
query:
|
|
449
|
+
options?.createRevision && options.revisionLabel
|
|
450
|
+
? { revision_label: options.revisionLabel }
|
|
451
|
+
: undefined,
|
|
450
452
|
headers,
|
|
451
453
|
});
|
|
452
454
|
}
|
|
@@ -173,10 +173,32 @@ describe('fetchSignedUrl', () => {
|
|
|
173
173
|
|
|
174
174
|
it('honors a numeric Retry-After header when scheduling the retry', async () => {
|
|
175
175
|
fetchMock
|
|
176
|
-
.mockResolvedValueOnce(response(503, 'slow down', { 'retry-after': '
|
|
176
|
+
.mockResolvedValueOnce(response(503, 'slow down', { 'retry-after': '30' }))
|
|
177
177
|
.mockResolvedValueOnce(response(200, 'ok'));
|
|
178
|
-
const
|
|
178
|
+
const pending = fetchSignedUrl('https://storage/x');
|
|
179
|
+
await vi.advanceTimersByTimeAsync(29999);
|
|
180
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
181
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
182
|
+
const res = await pending;
|
|
179
183
|
expect(res.status).toBe(200);
|
|
180
184
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
181
185
|
});
|
|
182
186
|
});
|
|
187
|
+
|
|
188
|
+
describe('Retry-After request budget', () => {
|
|
189
|
+
it.each(['3600', '2147484', '1e308', new Date(Date.now() + 3600000).toUTCString()])(
|
|
190
|
+
'returns the intact response without an early retry for %s',
|
|
191
|
+
async (retryAfter) => {
|
|
192
|
+
const res = new Response('retry later', { status: 503, headers: { 'Retry-After': retryAfter } });
|
|
193
|
+
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(res);
|
|
194
|
+
try {
|
|
195
|
+
const result = await fetchSignedUrl('https://storage/x');
|
|
196
|
+
expect(result).toBe(res);
|
|
197
|
+
expect(await result.text()).toBe('retry later');
|
|
198
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
199
|
+
} finally {
|
|
200
|
+
fetch.mockRestore();
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
});
|
package/src/store/signed-url.ts
CHANGED
|
@@ -20,6 +20,7 @@ const DEFAULT_RETRYABLE_STATUSES: ReadonlySet<number> = new Set([429, 500, 502,
|
|
|
20
20
|
const DEFAULT_ATTEMPTS = 4;
|
|
21
21
|
const DEFAULT_BASE_DELAY_MS = 500;
|
|
22
22
|
const DEFAULT_MAX_DELAY_MS = 8_000;
|
|
23
|
+
const MAX_RETRY_AFTER_MS = 60_000;
|
|
23
24
|
|
|
24
25
|
export interface SignedUrlFetchOptions {
|
|
25
26
|
method?: string;
|
|
@@ -29,7 +30,7 @@ export interface SignedUrlFetchOptions {
|
|
|
29
30
|
attempts?: number;
|
|
30
31
|
/** Base delay for exponential backoff, in milliseconds. Defaults to 500. */
|
|
31
32
|
baseDelayMs?: number;
|
|
32
|
-
/** Upper bound for
|
|
33
|
+
/** Upper bound for exponential backoff, in milliseconds. Defaults to 8000. Retry-After takes precedence. */
|
|
33
34
|
maxDelayMs?: number;
|
|
34
35
|
/** HTTP statuses to retry on. Defaults to 429, 500, 502, 503, 504. */
|
|
35
36
|
retryableStatuses?: ReadonlySet<number>;
|
|
@@ -58,7 +59,7 @@ function retryAfterMs(res: Response): number | undefined {
|
|
|
58
59
|
function backoffMs(attempt: number, baseDelayMs: number, maxDelayMs: number, res?: Response): number {
|
|
59
60
|
const retryAfter = res ? retryAfterMs(res) : undefined;
|
|
60
61
|
if (retryAfter !== undefined) {
|
|
61
|
-
return
|
|
62
|
+
return retryAfter;
|
|
62
63
|
}
|
|
63
64
|
// Exponential backoff with full jitter.
|
|
64
65
|
const capped = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
|
|
@@ -141,7 +142,8 @@ async function toReplayableBody(body: BodyInit | null | undefined): Promise<Body
|
|
|
141
142
|
/**
|
|
142
143
|
* `fetch()` a cloud-storage signed URL, retrying transient failures (connection
|
|
143
144
|
* errors and retryable HTTP statuses) with exponential backoff. Honors a
|
|
144
|
-
* `Retry-After` response header
|
|
145
|
+
* `Retry-After` response header up to 60 seconds. Longer delays return the response
|
|
146
|
+
* without retrying so the caller can schedule recovery outside this request.
|
|
145
147
|
*
|
|
146
148
|
* The returned `Response` is only guaranteed to be retried while it carries a
|
|
147
149
|
* retryable status; a non-retryable error response (e.g. 403, 404) is returned
|
|
@@ -167,6 +169,9 @@ export async function fetchSignedUrl(url: string, options: SignedUrlFetchOptions
|
|
|
167
169
|
if (res.ok || !retryableStatuses.has(res.status) || isLastAttempt) {
|
|
168
170
|
return res;
|
|
169
171
|
}
|
|
172
|
+
// Do not retry earlier than requested or park the caller beyond the retry budget.
|
|
173
|
+
// Returning the response also avoids overflowing JavaScript's timer range.
|
|
174
|
+
if ((retryAfterMs(res) ?? 0) > MAX_RETRY_AFTER_MS) return res;
|
|
170
175
|
// Retryable status: drain the body so the connection can be reused, then back off.
|
|
171
176
|
await res.body?.cancel().catch(() => undefined);
|
|
172
177
|
await sleep(backoffMs(attempt, baseDelayMs, maxDelayMs, res));
|