Skip to content

Commit 04e35f0

Browse files
authored
feat: knowledge base indexing poller (#14)
1 parent ff54b03 commit 04e35f0

File tree

6 files changed

+273
-2
lines changed

6 files changed

+273
-2
lines changed

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,6 @@ If you are interested in other runtime environments, please open or upvote an is
401401

402402
See [the contributing documentation](./CONTRIBUTING.md).
403403

404-
405404
## License
406405

407-
Licensed under the Apache License 2.0. See [LICENSE.](./LICENSE)
406+
Licensed under the Apache License 2.0. See [LICENSE.](./LICENSE)

api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,7 @@ Methods:
749749
- <code title="get /v2/gen-ai/indexing_jobs">client.knowledgeBases.indexingJobs.<a href="./src/resources/knowledge-bases/indexing-jobs.ts">list</a>({ ...params }) -> IndexingJobListResponse</code>
750750
- <code title="get /v2/gen-ai/indexing_jobs/{indexing_job_uuid}/data_sources">client.knowledgeBases.indexingJobs.<a href="./src/resources/knowledge-bases/indexing-jobs.ts">retrieveDataSources</a>(indexingJobUuid) -> IndexingJobRetrieveDataSourcesResponse</code>
751751
- <code title="put /v2/gen-ai/indexing_jobs/{uuid}/cancel">client.knowledgeBases.indexingJobs.<a href="./src/resources/knowledge-bases/indexing-jobs.ts">updateCancel</a>(pathUuid, { ...params }) -> IndexingJobUpdateCancelResponse</code>
752+
- <code title="polling helper">client.knowledgeBases.indexingJobs.<a href="./src/resources/knowledge-bases/indexing-jobs.ts">waitForCompletion</a>(uuid, { ...options }) -> IndexingJobRetrieveResponse</code>
752753

753754
# Models
754755

src/client.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,13 @@ export class Gradient {
853853
static PermissionDeniedError = Errors.PermissionDeniedError;
854854
static UnprocessableEntityError = Errors.UnprocessableEntityError;
855855

856+
// Indexing Job Error Types
857+
static IndexingJobAbortedError = API.KnowledgeBases.IndexingJobs.IndexingJobAbortedError;
858+
static IndexingJobNotFoundError = API.KnowledgeBases.IndexingJobs.IndexingJobNotFoundError;
859+
static IndexingJobFailedError = API.KnowledgeBases.IndexingJobs.IndexingJobFailedError;
860+
static IndexingJobCancelledError = API.KnowledgeBases.IndexingJobs.IndexingJobCancelledError;
861+
static IndexingJobTimeoutError = API.KnowledgeBases.IndexingJobs.IndexingJobTimeoutError;
862+
856863
static toFile = Uploads.toFile;
857864

858865
agents: API.Agents = new API.Agents(this);

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,10 @@ export {
2020
PermissionDeniedError,
2121
UnprocessableEntityError,
2222
} from './core/error';
23+
export {
24+
IndexingJobAbortedError,
25+
IndexingJobNotFoundError,
26+
IndexingJobFailedError,
27+
IndexingJobCancelledError,
28+
IndexingJobTimeoutError,
29+
} from './resources/knowledge-bases/indexing-jobs';

src/resources/knowledge-bases/indexing-jobs.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,57 @@ import * as Shared from '../shared';
55
import { APIPromise } from '../../core/api-promise';
66
import { RequestOptions } from '../../internal/request-options';
77
import { path } from '../../internal/utils/path';
8+
import { sleep } from '../../internal/utils/sleep';
9+
10+
/**
11+
* Error thrown when an indexing job polling operation is aborted
12+
*/
13+
export class IndexingJobAbortedError extends Error {
14+
constructor() {
15+
super('Indexing job polling was aborted');
16+
this.name = 'IndexingJobAbortedError';
17+
}
18+
}
19+
20+
/**
21+
* Error thrown when an indexing job is not found
22+
*/
23+
export class IndexingJobNotFoundError extends Error {
24+
constructor() {
25+
super('Indexing job not found');
26+
this.name = 'IndexingJobNotFoundError';
27+
}
28+
}
29+
30+
/**
31+
* Error thrown when an indexing job fails
32+
*/
33+
export class IndexingJobFailedError extends Error {
34+
constructor(public readonly phase: string) {
35+
super(`Indexing job failed with phase: ${phase}`);
36+
this.name = 'IndexingJobFailedError';
37+
}
38+
}
39+
40+
/**
41+
* Error thrown when an indexing job is cancelled
42+
*/
43+
export class IndexingJobCancelledError extends Error {
44+
constructor() {
45+
super('Indexing job was cancelled');
46+
this.name = 'IndexingJobCancelledError';
47+
}
48+
}
49+
50+
/**
51+
* Error thrown when an indexing job polling times out
52+
*/
53+
export class IndexingJobTimeoutError extends Error {
54+
constructor(public readonly timeoutMs: number) {
55+
super(`Indexing job polling timed out after ${timeoutMs}ms`);
56+
this.name = 'IndexingJobTimeoutError';
57+
}
58+
}
859

960
export class IndexingJobs extends APIResource {
1061
/**
@@ -113,6 +164,149 @@ export class IndexingJobs extends APIResource {
113164
...options,
114165
});
115166
}
167+
168+
/**
169+
* Polls for indexing job completion with configurable interval and timeout.
170+
* Uses exponential backoff to gradually increase polling intervals, reducing API load for long-running jobs.
171+
* Returns the final job state when completed, failed, or cancelled.
172+
*
173+
* **Exponential Backoff Behavior:**
174+
* - First 2 polls use the initial interval
175+
* - Starting from the 3rd poll, the interval doubles after each poll
176+
* - The interval is capped at the `maxInterval` value
177+
* - Example with default values (interval: 5000ms, maxInterval: 30000ms):
178+
* - Poll 1: 5000ms wait
179+
* - Poll 2: 5000ms wait
180+
* - Poll 3: 10000ms wait (5000 * 2)
181+
* - Poll 4: 20000ms wait (10000 * 2)
182+
* - Poll 5: 30000ms wait (20000 * 1.5, capped at maxInterval)
183+
* - Poll 6+: 30000ms wait (continues at maxInterval)
184+
*
185+
* @param uuid - The indexing job UUID to poll
186+
* @param options - Polling configuration options
187+
* @returns Promise that resolves with the final job state
188+
* @throws {IndexingJobAbortedError} When the operation is aborted via AbortSignal
189+
* @throws {IndexingJobNotFoundError} When the job is not found
190+
* @throws {IndexingJobFailedError} When the job fails with phase FAILED or ERROR
191+
* @throws {IndexingJobCancelledError} When the job is cancelled
192+
* @throws {IndexingJobTimeoutError} When polling times out
193+
*
194+
* @example
195+
* ```ts
196+
* const job = await client.knowledgeBases.indexingJobs.waitForCompletion(
197+
* '123e4567-e89b-12d3-a456-426614174000',
198+
* { interval: 5000, timeout: 300000 }
199+
* );
200+
* console.log('Job completed with phase:', job.job?.phase);
201+
* ```
202+
*
203+
* @example
204+
* ```ts
205+
* const controller = new AbortController();
206+
* const job = await client.knowledgeBases.indexingJobs.waitForCompletion(
207+
* '123e4567-e89b-12d3-a456-426614174000',
208+
* { requestOptions: { signal: controller.signal } }
209+
* );
210+
* // Cancel polling after 30 seconds
211+
* setTimeout(() => controller.abort(), 30000);
212+
* ```
213+
*
214+
* @example
215+
* ```ts
216+
* // Custom exponential backoff configuration
217+
* const job = await client.knowledgeBases.indexingJobs.waitForCompletion(uuid, {
218+
* interval: 2000, // Start with 2 second intervals
219+
* maxInterval: 60000, // Cap at 1 minute intervals
220+
* timeout: 1800000 // 30 minute timeout
221+
* });
222+
* ```
223+
*
224+
* @example
225+
* ```ts
226+
* try {
227+
* const job = await client.knowledgeBases.indexingJobs.waitForCompletion(uuid);
228+
* console.log('Job completed successfully');
229+
* } catch (error) {
230+
* if (error instanceof IndexingJobFailedError) {
231+
* console.log('Job failed with phase:', error.phase);
232+
* } else if (error instanceof IndexingJobTimeoutError) {
233+
* console.log('Job timed out after:', error.timeoutMs, 'ms');
234+
* } else if (error instanceof IndexingJobAbortedError) {
235+
* console.log('Job polling was aborted');
236+
* }
237+
* }
238+
* ```
239+
*/
240+
async waitForCompletion(
241+
uuid: string,
242+
options: {
243+
/**
244+
* Initial polling interval in milliseconds (default: 5000ms).
245+
* This interval will be used for the first few polls, then exponential backoff applies.
246+
*/
247+
interval?: number;
248+
/**
249+
* Maximum time to wait in milliseconds (default: 600000ms = 10 minutes)
250+
*/
251+
timeout?: number;
252+
/**
253+
* Maximum polling interval in milliseconds (default: 30000ms = 30 seconds).
254+
* Exponential backoff will not exceed this value.
255+
*/
256+
maxInterval?: number;
257+
/**
258+
* Request options to pass to each poll request
259+
*/
260+
requestOptions?: RequestOptions;
261+
} = {},
262+
): Promise<IndexingJobRetrieveResponse> {
263+
const { interval = 5000, timeout = 600000, maxInterval = 30000, requestOptions } = options;
264+
const startTime = Date.now();
265+
let currentInterval = interval;
266+
let pollCount = 0;
267+
268+
while (true) {
269+
// Check if operation was aborted
270+
if (requestOptions?.signal?.aborted) {
271+
throw new IndexingJobAbortedError();
272+
}
273+
274+
const response = await this.retrieve(uuid, requestOptions);
275+
const job = response.job;
276+
277+
if (!job) {
278+
throw new IndexingJobNotFoundError();
279+
}
280+
281+
// Check if job is in a terminal state
282+
if (job.phase === 'BATCH_JOB_PHASE_SUCCEEDED') {
283+
return response;
284+
}
285+
286+
if (job.phase === 'BATCH_JOB_PHASE_FAILED' || job.phase === 'BATCH_JOB_PHASE_ERROR') {
287+
throw new IndexingJobFailedError(job.phase);
288+
}
289+
290+
if (job.phase === 'BATCH_JOB_PHASE_CANCELLED') {
291+
throw new IndexingJobCancelledError();
292+
}
293+
294+
// Check timeout
295+
if (Date.now() - startTime > timeout) {
296+
throw new IndexingJobTimeoutError(timeout);
297+
}
298+
299+
// Wait before next poll with exponential backoff
300+
await sleep(currentInterval);
301+
302+
// Apply exponential backoff: double the interval after each poll, up to maxInterval
303+
pollCount++;
304+
if (pollCount > 2) {
305+
// Start exponential backoff after 2 polls
306+
currentInterval = Math.min(currentInterval * 2, maxInterval);
307+
}
308+
}
309+
}
116310
}
117311

118312
export interface APIIndexedDataSource {
@@ -367,5 +561,10 @@ export declare namespace IndexingJobs {
367561
type IndexingJobCreateParams as IndexingJobCreateParams,
368562
type IndexingJobListParams as IndexingJobListParams,
369563
type IndexingJobUpdateCancelParams as IndexingJobUpdateCancelParams,
564+
IndexingJobAbortedError,
565+
IndexingJobNotFoundError,
566+
IndexingJobFailedError,
567+
IndexingJobCancelledError,
568+
IndexingJobTimeoutError,
370569
};
371570
}

tests/api-resources/knowledge-bases/indexing-jobs.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,62 @@ describe('resource indexingJobs', () => {
107107
),
108108
).rejects.toThrow(Gradient.NotFoundError);
109109
});
110+
111+
describe('waitForCompletion', () => {
112+
// Prism tests are disabled
113+
test.skip('waits for job completion successfully', async () => {
114+
const jobUuid = '123e4567-e89b-12d3-a456-426614174000';
115+
const responsePromise = client.knowledgeBases.indexingJobs.waitForCompletion(jobUuid, {
116+
interval: 100,
117+
timeout: 1000,
118+
});
119+
const response = await responsePromise;
120+
expect(response).toBeDefined();
121+
expect(response.job).toBeDefined();
122+
});
123+
124+
// Prism tests are disabled
125+
test.skip('throws error when job fails', async () => {
126+
const jobUuid = '123e4567-e89b-12d3-a456-426614174000';
127+
await expect(
128+
client.knowledgeBases.indexingJobs.waitForCompletion(jobUuid, {
129+
interval: 100,
130+
timeout: 1000,
131+
}),
132+
).rejects.toThrow();
133+
});
134+
135+
// Prism tests are disabled
136+
test.skip('throws error when job is cancelled', async () => {
137+
const jobUuid = '123e4567-e89b-12d3-a456-426614174000';
138+
await expect(
139+
client.knowledgeBases.indexingJobs.waitForCompletion(jobUuid, {
140+
interval: 100,
141+
timeout: 1000,
142+
}),
143+
).rejects.toThrow('Indexing job was cancelled');
144+
});
145+
146+
// Prism tests are disabled
147+
test.skip('throws error when timeout is reached', async () => {
148+
const jobUuid = '123e4567-e89b-12d3-a456-426614174000';
149+
await expect(
150+
client.knowledgeBases.indexingJobs.waitForCompletion(jobUuid, {
151+
interval: 100,
152+
timeout: 50, // Very short timeout
153+
}),
154+
).rejects.toThrow('Indexing job polling timed out');
155+
});
156+
157+
// Prism tests are disabled
158+
test.skip('throws error when job is not found', async () => {
159+
const jobUuid = 'nonexistent-job-uuid';
160+
await expect(
161+
client.knowledgeBases.indexingJobs.waitForCompletion(jobUuid, {
162+
interval: 100,
163+
timeout: 1000,
164+
}),
165+
).rejects.toThrow('Job not found');
166+
});
167+
});
110168
});

0 commit comments

Comments
 (0)