@vertesia/client 1.5.0-dev.20260910.052912Z → 1.5.1
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/EnvironmentsApi.d.ts +6 -1
- package/lib/EnvironmentsApi.d.ts.map +1 -1
- package/lib/EnvironmentsApi.js +15 -0
- package/lib/EnvironmentsApi.js.map +1 -1
- package/lib/InteractionBase.d.ts +108 -60
- package/lib/InteractionBase.d.ts.map +1 -1
- package/lib/InteractionsApi.d.ts +2 -7
- package/lib/InteractionsApi.d.ts.map +1 -1
- package/lib/InteractionsApi.js.map +1 -1
- package/lib/InternalAccountsApi.d.ts +7 -0
- package/lib/InternalAccountsApi.d.ts.map +1 -0
- package/lib/InternalAccountsApi.js +10 -0
- package/lib/InternalAccountsApi.js.map +1 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/store/AgentsApi.d.ts.map +1 -1
- package/lib/store/AgentsApi.js +15 -0
- package/lib/store/AgentsApi.js.map +1 -1
- package/lib/store/EmbeddingsApi.d.ts +8 -2
- package/lib/store/EmbeddingsApi.d.ts.map +1 -1
- package/lib/store/EmbeddingsApi.js +20 -2
- package/lib/store/EmbeddingsApi.js.map +1 -1
- 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 +3 -3
- package/lib/vertesia-client.js.map +1 -1
- package/package.json +5 -5
- package/src/EnvironmentsApi.ts +28 -0
- package/src/InteractionsApi.ts +2 -9
- package/src/InternalAccountsApi.ts +12 -0
- package/src/index.ts +1 -0
- package/src/store/AgentsApi.stream.test.ts +33 -1
- package/src/store/AgentsApi.ts +17 -0
- package/src/store/EmbeddingsApi.test.ts +31 -0
- package/src/store/EmbeddingsApi.ts +47 -2
- 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.
|
|
3
|
+
"version": "1.5.1",
|
|
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.1",
|
|
23
|
+
"@vertesia/api-fetch-client": "1.5.1",
|
|
24
|
+
"@vertesia/common": "1.5.1"
|
|
25
25
|
},
|
|
26
26
|
"exports": {
|
|
27
27
|
".": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"ai",
|
|
49
49
|
"typescript"
|
|
50
50
|
],
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "7ab3acd95af12b8c254e1b3852e67d5078790f98",
|
|
52
52
|
"scripts": {
|
|
53
53
|
"lint": "biome lint src",
|
|
54
54
|
"clean:lib": "rimraf ./lib ./tsconfig.tsbuildinfo",
|
package/src/EnvironmentsApi.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { AIModel, ModelSearchPayload } from '@llumiverse/common';
|
|
2
2
|
import { ApiTopic, type ClientBase } from '@vertesia/api-fetch-client';
|
|
3
3
|
import type {
|
|
4
|
+
EmbeddingBatchCapabilityRequest,
|
|
5
|
+
EmbeddingBatchCapabilityResponse,
|
|
6
|
+
EmbeddingBatchCreateRequest,
|
|
7
|
+
EmbeddingBatchJobRequest,
|
|
8
|
+
EmbeddingBatchJobResponse,
|
|
4
9
|
EmbeddingsApiRequest,
|
|
5
10
|
EmbeddingsApiResult,
|
|
6
11
|
EnableEnvironmentModelPayload,
|
|
@@ -84,6 +89,29 @@ export default class EnvironmentsApi extends ApiTopic {
|
|
|
84
89
|
});
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
embeddingBatchCapability(
|
|
93
|
+
id: string,
|
|
94
|
+
payload: EmbeddingBatchCapabilityRequest,
|
|
95
|
+
): Promise<EmbeddingBatchCapabilityResponse> {
|
|
96
|
+
return this.post(`/${id}/embedding-batches/capability`, { payload });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
createEmbeddingBatch(id: string, payload: EmbeddingBatchCreateRequest): Promise<EmbeddingBatchJobResponse> {
|
|
100
|
+
return this.post(`/${id}/embedding-batches`, { payload });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getEmbeddingBatch(id: string, payload: EmbeddingBatchJobRequest): Promise<EmbeddingBatchJobResponse> {
|
|
104
|
+
return this.post(`/${id}/embedding-batches/get`, { payload });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
cancelEmbeddingBatch(id: string, payload: EmbeddingBatchJobRequest): Promise<EmbeddingBatchJobResponse> {
|
|
108
|
+
return this.post(`/${id}/embedding-batches/cancel`, { payload });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
deleteEmbeddingBatch(id: string, payload: EmbeddingBatchJobRequest): Promise<EmbeddingBatchJobResponse> {
|
|
112
|
+
return this.post(`/${id}/embedding-batches/delete`, { payload });
|
|
113
|
+
}
|
|
114
|
+
|
|
87
115
|
/**
|
|
88
116
|
* Batch update the LLM Environment for multiple Interactions, including the model used and the
|
|
89
117
|
* Environment that they belong to.
|
package/src/InteractionsApi.ts
CHANGED
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
RateLimitRequestPayload,
|
|
31
31
|
RateLimitRequestResponse,
|
|
32
32
|
ResolvedInteractionExecutionInfo,
|
|
33
|
+
ResolveInteractionQuery,
|
|
33
34
|
} from '@vertesia/common';
|
|
34
35
|
import type { VertesiaClient } from './client.js';
|
|
35
36
|
import { checkRateLimit, executeInteraction, executeInteractionAsync, executeInteractionByName } from './execute.js';
|
|
@@ -339,15 +340,7 @@ export default class InteractionsApi extends ApiTopic {
|
|
|
339
340
|
* @param options Optional environment and/or model to resolve with
|
|
340
341
|
* @returns ResolvedInteractionExecutionInfo with the resolved environment and model
|
|
341
342
|
*/
|
|
342
|
-
resolve(
|
|
343
|
-
nameOrId: string,
|
|
344
|
-
options?: {
|
|
345
|
-
environment?: string;
|
|
346
|
-
model?: string;
|
|
347
|
-
hasImage?: boolean;
|
|
348
|
-
hasVideo?: boolean;
|
|
349
|
-
},
|
|
350
|
-
): Promise<ResolvedInteractionExecutionInfo> {
|
|
343
|
+
resolve(nameOrId: string, options?: ResolveInteractionQuery): Promise<ResolvedInteractionExecutionInfo> {
|
|
351
344
|
return this.get(`/resolve/${encodeURIComponent(nameOrId)}`, {
|
|
352
345
|
query: options,
|
|
353
346
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ApiTopic, type ClientBase } from '@vertesia/api-fetch-client';
|
|
2
|
+
import type { AccountApiVersionPolicy } from '@vertesia/common';
|
|
3
|
+
|
|
4
|
+
export default class InternalAccountsApi extends ApiTopic {
|
|
5
|
+
constructor(parent: ClientBase) {
|
|
6
|
+
super(parent, '/internal/accounts');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
getApiVersionPolicy(accountId: string): Promise<AccountApiVersionPolicy> {
|
|
10
|
+
return this.get(`/${encodeURIComponent(accountId)}/api-version-policy`);
|
|
11
|
+
}
|
|
12
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './client.js';
|
|
|
6
6
|
export type { GroupsQueryOptions } from './GroupsApi.js';
|
|
7
7
|
export * from './InteractionBase.js';
|
|
8
8
|
export * from './InteractionOutput.js';
|
|
9
|
+
export { default as InternalAccountsApi } from './InternalAccountsApi.js';
|
|
9
10
|
export { default as InternalSecretsApi } from './InternalSecretsApi.js';
|
|
10
11
|
export * from './managed-sse.js';
|
|
11
12
|
export { default as OAuthClientsApi } from './OAuthClientsApi.js';
|
|
@@ -72,7 +72,16 @@ function createHarness(): Harness {
|
|
|
72
72
|
}
|
|
73
73
|
const since = Number(url.searchParams.get('since') ?? 0);
|
|
74
74
|
pollSince.push(since);
|
|
75
|
-
|
|
75
|
+
try {
|
|
76
|
+
return Response.json({ messages: pollResponse(since) });
|
|
77
|
+
} catch (err) {
|
|
78
|
+
// A thrown error carrying `status` models an HTTP error response (e.g. 404).
|
|
79
|
+
const status = (err as { status?: unknown }).status;
|
|
80
|
+
if (typeof status === 'number') {
|
|
81
|
+
return Response.json({ error: (err as Error).message }, { status });
|
|
82
|
+
}
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
76
85
|
}
|
|
77
86
|
if (url.pathname.endsWith(`/${AGENT_RUN_ID}`)) {
|
|
78
87
|
return Response.json({ id: AGENT_RUN_ID, status: runStatus });
|
|
@@ -337,6 +346,29 @@ describe('AgentsApi.streamMessages reconnection', () => {
|
|
|
337
346
|
await settle();
|
|
338
347
|
});
|
|
339
348
|
|
|
349
|
+
it('stops the polling fallback when the run no longer exists (404)', async () => {
|
|
350
|
+
const harness = createHarness();
|
|
351
|
+
harness.setHistory([{ t: AgentMessageType.UPDATE, m: 'history', ts: 1000 }]);
|
|
352
|
+
harness.setPollResponse(() => {
|
|
353
|
+
throw Object.assign(new Error('Not Found: Agent run not found'), { status: 404 });
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const abort = new AbortController();
|
|
357
|
+
const done = harness.client.agents.streamMessages(AGENT_RUN_ID, undefined, undefined, abort.signal);
|
|
358
|
+
await settle();
|
|
359
|
+
|
|
360
|
+
for (let i = 0; i < 11; i++) {
|
|
361
|
+
await failCurrentConnection();
|
|
362
|
+
}
|
|
363
|
+
await settle(6000);
|
|
364
|
+
|
|
365
|
+
await expect(done).resolves.toBeNull();
|
|
366
|
+
expect(warnings.some((line) => line.includes('the run no longer exists (404)'))).toBe(true);
|
|
367
|
+
// A single 404 ends the loop: no retry warning, no further poll after the exit.
|
|
368
|
+
expect(warnings.filter((line) => line.includes('GET /updates failed while polling'))).toEqual([]);
|
|
369
|
+
expect(harness.pollSince).toHaveLength(1);
|
|
370
|
+
});
|
|
371
|
+
|
|
340
372
|
it('closes the stream when polling observes a terminal run status', async () => {
|
|
341
373
|
const harness = createHarness();
|
|
342
374
|
const abort = new AbortController();
|
package/src/store/AgentsApi.ts
CHANGED
|
@@ -417,6 +417,15 @@ export class AgentsApi extends ApiTopic {
|
|
|
417
417
|
);
|
|
418
418
|
};
|
|
419
419
|
|
|
420
|
+
// A 404 while polling means the run itself is gone (deleted, or never persisted): nothing
|
|
421
|
+
// will ever arrive, so stop instead of polling the missing resource every 5s forever.
|
|
422
|
+
const isRunGone = (error: unknown): boolean =>
|
|
423
|
+
typeof error === 'object' && error !== null && (error as { status?: unknown }).status === 404;
|
|
424
|
+
const exitBecauseRunGone = () => {
|
|
425
|
+
console.warn(`Agent stream ${id}: the run no longer exists (404); stopping the polling fallback.`);
|
|
426
|
+
exit(null);
|
|
427
|
+
};
|
|
428
|
+
|
|
420
429
|
const pollTick = async () => {
|
|
421
430
|
if (isClosed || !isPolling) return;
|
|
422
431
|
let polledMessages = false;
|
|
@@ -438,6 +447,10 @@ export class AgentsApi extends ApiTopic {
|
|
|
438
447
|
}
|
|
439
448
|
}
|
|
440
449
|
} catch (err) {
|
|
450
|
+
if (isRunGone(err)) {
|
|
451
|
+
exitBecauseRunGone();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
441
454
|
warnPollFailure('GET /updates', err);
|
|
442
455
|
}
|
|
443
456
|
if (isClosed || !isPolling) return;
|
|
@@ -450,6 +463,10 @@ export class AgentsApi extends ApiTopic {
|
|
|
450
463
|
}
|
|
451
464
|
if (polledMessages) consecutivePollFailures = 0;
|
|
452
465
|
} catch (err) {
|
|
466
|
+
if (isRunGone(err)) {
|
|
467
|
+
exitBecauseRunGone();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
453
470
|
warnPollFailure('run status check', err);
|
|
454
471
|
}
|
|
455
472
|
if (isClosed || !isPolling) return;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { SupportedEmbeddingTypes } from '@vertesia/common';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { ZenoClient } from './client.js';
|
|
4
|
+
|
|
5
|
+
describe('EmbeddingsApi', () => {
|
|
6
|
+
it('omits the mode by default and sends the explicit sync override when requested', async () => {
|
|
7
|
+
const requests: Request[] = [];
|
|
8
|
+
const client = new ZenoClient({
|
|
9
|
+
serverUrl: 'https://store.example.com',
|
|
10
|
+
apikey: 'token',
|
|
11
|
+
fetch: vi.fn(
|
|
12
|
+
async () =>
|
|
13
|
+
new Response(JSON.stringify({ status: 'success', message: 'started' }), {
|
|
14
|
+
status: 200,
|
|
15
|
+
headers: { 'content-type': 'application/json' },
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
onRequest: (request) => requests.push(request),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
await client.embeddings.recalculate(SupportedEmbeddingTypes.text);
|
|
22
|
+
await client.embeddings.recalculate(SupportedEmbeddingTypes.text, { mode: 'sync' });
|
|
23
|
+
await client.embeddings.recalculate(SupportedEmbeddingTypes.image, { force: true });
|
|
24
|
+
|
|
25
|
+
expect(requests.map(({ url }) => url)).toEqual([
|
|
26
|
+
'https://store.example.com/api/v1/embeddings/text/recalculate',
|
|
27
|
+
'https://store.example.com/api/v1/embeddings/text/recalculate?mode=sync',
|
|
28
|
+
'https://store.example.com/api/v1/embeddings/image/recalculate?force=true',
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { ApiTopic, type ClientBase } from '@vertesia/api-fetch-client';
|
|
2
2
|
import type {
|
|
3
|
+
EmbeddingBatchApplyRequest,
|
|
4
|
+
EmbeddingBatchApplyResponse,
|
|
5
|
+
EmbeddingBatchPrepareRequest,
|
|
6
|
+
EmbeddingBatchPrepareResponse,
|
|
7
|
+
EmbeddingBatchRenditionPageRequest,
|
|
8
|
+
EmbeddingBatchRenditionPageResponse,
|
|
9
|
+
EmbeddingBatchUpdateRequest,
|
|
3
10
|
EmbeddingsStatusResponse,
|
|
4
11
|
GenericCommandResponse,
|
|
5
12
|
ProjectConfigurationEmbeddingEnablePayload,
|
|
13
|
+
RecalculateEmbeddingsQuery,
|
|
6
14
|
SupportedEmbeddingTypes,
|
|
7
15
|
} from '@vertesia/common';
|
|
8
16
|
|
|
@@ -33,7 +41,44 @@ export class EmbeddingsApi extends ApiTopic {
|
|
|
33
41
|
return this.post(`${type}/disable`);
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
async recalculate(
|
|
37
|
-
|
|
44
|
+
async recalculate(
|
|
45
|
+
type: SupportedEmbeddingTypes,
|
|
46
|
+
query: RecalculateEmbeddingsQuery = {},
|
|
47
|
+
): Promise<GenericCommandResponse> {
|
|
48
|
+
return this.post(`${type}/recalculate`, Object.keys(query).length ? { query } : undefined);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async prepareBatch(
|
|
52
|
+
payload: EmbeddingBatchPrepareRequest,
|
|
53
|
+
timeoutMs: number | false | null = false,
|
|
54
|
+
): Promise<EmbeddingBatchPrepareResponse> {
|
|
55
|
+
return this.post('/batch/prepare', { payload, timeoutMs });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async updateBatch(payload: EmbeddingBatchUpdateRequest): Promise<GenericCommandResponse> {
|
|
59
|
+
return this.post('/batch/update', { payload });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async prepareBatchRenditionPage(
|
|
63
|
+
payload: EmbeddingBatchRenditionPageRequest,
|
|
64
|
+
): Promise<EmbeddingBatchRenditionPageResponse> {
|
|
65
|
+
return this.post('/batch/renditions/prepare', { payload });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async pollBatchRenditionPage(
|
|
69
|
+
payload: EmbeddingBatchRenditionPageRequest,
|
|
70
|
+
): Promise<EmbeddingBatchRenditionPageResponse> {
|
|
71
|
+
return this.post('/batch/renditions/status', { payload });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async cancelBatchRenditionPage(payload: EmbeddingBatchRenditionPageRequest): Promise<GenericCommandResponse> {
|
|
75
|
+
return this.post('/batch/renditions/cancel', { payload });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async applyBatch(
|
|
79
|
+
payload: EmbeddingBatchApplyRequest,
|
|
80
|
+
timeoutMs: number | false | null = false,
|
|
81
|
+
): Promise<EmbeddingBatchApplyResponse> {
|
|
82
|
+
return this.post('/batch/apply', { payload, timeoutMs });
|
|
38
83
|
}
|
|
39
84
|
}
|
|
@@ -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));
|