@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertesia/client",
3
- "version": "1.5.0-dev.20260910.052912Z",
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
- "@vertesia/api-fetch-client": "1.5.0-dev.20260910.052912Z",
23
- "@vertesia/common": "1.5.0-dev.20260910.052912Z",
24
- "@llumiverse/common": "1.5.0-dev.20260910.044936Z"
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": "95d481ca856012cd67b501b901dc365afeb7cf81",
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
+ });
@@ -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': '2' }))
176
+ .mockResolvedValueOnce(response(503, 'slow down', { 'retry-after': '30' }))
177
177
  .mockResolvedValueOnce(response(200, 'ok'));
178
- const res = await runAllTimers(fetchSignedUrl('https://storage/x'));
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
+ });
@@ -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 a single backoff delay, in milliseconds. Defaults to 8000. */
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 Math.min(maxDelayMs, retryAfter);
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 when present.
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));