ai-functions
Version:
Core AI primitives for building intelligent applications
611 lines (525 loc) • 19.8 kB
text/typescript
/**
* Tests for batch auto-submit error scenarios
*
* RED PHASE: These tests expose error handling gaps in BatchQueue's auto-submit feature.
*
* The problem: When auto-submit triggers on maxItems threshold, errors from submit()
* are caught and only logged (line 250: `this.submit().catch(console.error)`).
* This means callers have no way to know the batch submission failed.
*
* Test scenarios:
* - Network failure during batch submit
* - Rate limit errors from API
* - Partial batch success/failure
* - Timeout during submission
*
* @see primitives.org.ai-7au
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
createBatch,
BatchQueue,
registerBatchAdapter,
type BatchAdapter,
type BatchItem,
type BatchQueueOptions,
type BatchResult,
type BatchJob,
type BatchSubmitResult,
} from '../src/batch-queue.js'
// ============================================================================
// Test Helpers
// ============================================================================
/**
* Create a mock adapter that fails on submit
*/
function createFailingAdapter(error: Error): BatchAdapter {
return {
async submit(): Promise<BatchSubmitResult> {
throw error
},
async getStatus(batchId: string): Promise<BatchJob> {
return {
id: batchId,
provider: 'openai',
status: 'failed',
totalItems: 0,
completedItems: 0,
failedItems: 0,
createdAt: new Date(),
}
},
async cancel(): Promise<void> {},
async getResults(): Promise<BatchResult[]> {
return []
},
async waitForCompletion(): Promise<BatchResult[]> {
return []
},
}
}
/**
* Create a mock adapter that succeeds
*/
function createSuccessAdapter(): BatchAdapter {
let batchCounter = 0
return {
async submit(items: BatchItem[], options: BatchQueueOptions): Promise<BatchSubmitResult> {
const batchId = `batch_test_${++batchCounter}`
const results: BatchResult[] = items.map((item) => ({
id: item.id,
customId: item.id,
status: 'completed' as const,
result: `Result for ${item.prompt}`,
}))
return {
job: {
id: batchId,
provider: options.provider || 'openai',
status: 'completed',
totalItems: items.length,
completedItems: items.length,
failedItems: 0,
createdAt: new Date(),
completedAt: new Date(),
},
completion: Promise.resolve(results),
}
},
async getStatus(batchId: string): Promise<BatchJob> {
return {
id: batchId,
provider: 'openai',
status: 'completed',
totalItems: 0,
completedItems: 0,
failedItems: 0,
createdAt: new Date(),
}
},
async cancel(): Promise<void> {},
async getResults(): Promise<BatchResult[]> {
return []
},
async waitForCompletion(): Promise<BatchResult[]> {
return []
},
}
}
/**
* Create an adapter that times out
*/
function createTimeoutAdapter(timeoutMs: number): BatchAdapter {
return {
async submit(): Promise<BatchSubmitResult> {
await new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timeout')), timeoutMs)
)
throw new Error('Request timeout')
},
async getStatus(batchId: string): Promise<BatchJob> {
return {
id: batchId,
provider: 'openai',
status: 'failed',
totalItems: 0,
completedItems: 0,
failedItems: 0,
createdAt: new Date(),
}
},
async cancel(): Promise<void> {},
async getResults(): Promise<BatchResult[]> {
return []
},
async waitForCompletion(): Promise<BatchResult[]> {
return []
},
}
}
/**
* Create an adapter that returns rate limit error
*/
function createRateLimitAdapter(): BatchAdapter {
return createFailingAdapter(
Object.assign(new Error('Rate limit exceeded'), {
status: 429,
headers: { 'retry-after': '60' },
})
)
}
// ============================================================================
// Tests
// ============================================================================
describe('Batch auto-submit error handling', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
// Capture console.error to verify errors are logged
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
consoleErrorSpy.mockRestore()
})
describe('Network failure during auto-submit', () => {
it('should emit an error event when auto-submit fails due to network error', async () => {
// Register failing adapter
const networkError = new Error('Network connection failed')
registerBatchAdapter('openai', createFailingAdapter(networkError))
const errorHandler = vi.fn()
const batch = createBatch({
provider: 'openai',
autoSubmit: true,
maxItems: 3,
})
// Subscribe to error events (this is what we expect to exist)
// This will fail because BatchQueue doesn't emit events
if ('on' in batch) {
;(batch as BatchQueue & { on: (event: string, handler: (e: Error) => void) => void }).on(
'error',
errorHandler
)
}
// Add items to trigger auto-submit
batch.add('prompt 1')
batch.add('prompt 2')
batch.add('prompt 3') // This should trigger auto-submit
// Wait for async auto-submit to complete
await new Promise((resolve) => setTimeout(resolve, 100))
// FAILING: Currently errors are swallowed, errorHandler never called
// The error should be propagated to the error handler
expect(errorHandler).toHaveBeenCalledWith(networkError)
})
it('should reject pending item promises when auto-submit fails', async () => {
const networkError = new Error('Network connection failed')
registerBatchAdapter('openai', createFailingAdapter(networkError))
const batch = createBatch({
provider: 'openai',
autoSubmit: true,
maxItems: 3,
})
// Get item references before auto-submit triggers
const item1 = batch.add('prompt 1')
const item2 = batch.add('prompt 2')
const item3 = batch.add('prompt 3') // Triggers auto-submit
// Wait for async auto-submit to complete
await new Promise((resolve) => setTimeout(resolve, 100))
// FAILING: Items should have error status after failed auto-submit
// Currently they remain in 'pending' status with no indication of failure
expect(item1.status).toBe('failed')
expect(item1.error).toBe('Network connection failed')
expect(item2.status).toBe('failed')
expect(item3.status).toBe('failed')
})
it('should provide a way to await auto-submit completion or failure', async () => {
const networkError = new Error('Network connection failed')
registerBatchAdapter('openai', createFailingAdapter(networkError))
const batch = createBatch({
provider: 'openai',
autoSubmit: true,
maxItems: 3,
})
batch.add('prompt 1')
batch.add('prompt 2')
batch.add('prompt 3') // Triggers auto-submit
// FAILING: There should be a way to await the auto-submit result
// Currently the submission is fire-and-forget with no way to await it
// Expected: batch.awaitAutoSubmit() or batch.getSubmissionPromise()
// This property should exist to allow awaiting auto-submit
expect('autoSubmitPromise' in batch).toBe(true)
// The promise should be available for awaiting
const autoSubmitPromise = (batch as BatchQueue & { autoSubmitPromise?: Promise<void> })
.autoSubmitPromise
expect(autoSubmitPromise).toBeDefined()
// Awaiting it should surface the error
await expect(autoSubmitPromise).rejects.toThrow('Network connection failed')
})
})
describe('Rate limit errors during auto-submit', () => {
it('should expose rate limit errors to callers', async () => {
registerBatchAdapter('openai', createRateLimitAdapter())
const batch = createBatch({
provider: 'openai',
autoSubmit: true,
maxItems: 2,
})
batch.add('prompt 1')
batch.add('prompt 2') // Triggers auto-submit
await new Promise((resolve) => setTimeout(resolve, 100))
// FAILING: Rate limit error should be exposed to caller
// Currently it's only logged to console.error
expect(consoleErrorSpy).toHaveBeenCalled()
// There should be a way to check for submission errors
// This property doesn't exist - that's the gap
const submissionError = (batch as BatchQueue & { submissionError?: Error }).submissionError
expect(submissionError).toBeDefined()
expect(submissionError?.message).toContain('Rate limit')
})
it('should include retry-after information in rate limit errors', async () => {
registerBatchAdapter('openai', createRateLimitAdapter())
const batch = createBatch({
provider: 'openai',
autoSubmit: true,
maxItems: 2,
})
batch.add('prompt 1')
batch.add('prompt 2')
await new Promise((resolve) => setTimeout(resolve, 100))
// FAILING: Rate limit metadata should be accessible
const job = batch.getJob()
// Job should contain rate limit retry info
expect(job).toBeDefined()
expect((job as BatchJob & { retryAfter?: number })?.retryAfter).toBeDefined()
})
})
describe('Timeout during auto-submit', () => {
it('should handle submission timeouts gracefully', async () => {
registerBatchAdapter('openai', createTimeoutAdapter(50))
const batch = createBatch({
provider: 'openai',
autoSubmit: true,
maxItems: 2,
})
batch.add('prompt 1')
batch.add('prompt 2')
// Wait for timeout to occur
await new Promise((resolve) => setTimeout(resolve, 200))
// FAILING: Timeout error should be captured and accessible
// Logger calls with format: 'Batch auto-submit failed:', error
expect(consoleErrorSpy).toHaveBeenCalledWith('Batch auto-submit failed:', expect.any(Error))
// Items should reflect the failure
const items = batch.getItems()
expect(items[0].status).toBe('failed')
expect(items[0].error).toContain('timeout')
})
})
describe('Error recovery scenarios', () => {
it('should allow retry after auto-submit failure', async () => {
const failingAdapter = createFailingAdapter(new Error('Temporary failure'))
registerBatchAdapter('openai', failingAdapter)
const batch = createBatch({
provider: 'openai',
autoSubmit: true,
maxItems: 2,
})
batch.add('prompt 1')
batch.add('prompt 2') // Triggers auto-submit (fails)
await new Promise((resolve) => setTimeout(resolve, 100))
// Replace with working adapter
registerBatchAdapter('openai', createSuccessAdapter())
// FAILING: There should be a way to retry failed auto-submit
// Currently once auto-submit fires and fails, the batch is stuck
const retry = () => {
if ('retry' in batch) {
return (batch as BatchQueue & { retry: () => Promise<void> }).retry()
}
return Promise.reject(new Error('No retry method available'))
}
// Should be able to retry the submission
await expect(retry()).resolves.not.toThrow()
})
it('should reset submission state on failure to allow manual submit', async () => {
registerBatchAdapter('openai', createFailingAdapter(new Error('Submit failed')))
const batch = createBatch({
provider: 'openai',
autoSubmit: true,
maxItems: 2,
})
batch.add('prompt 1')
batch.add('prompt 2') // Triggers auto-submit (fails)
await new Promise((resolve) => setTimeout(resolve, 100))
// Replace with working adapter
registerBatchAdapter('openai', createSuccessAdapter())
// FAILING: After auto-submit failure, manual submit should be possible
// Currently isSubmitted is set to true before the async submit completes
// so we can't retry with manual submit()
expect(batch.isSubmitted).toBe(false) // Should be false after failed auto-submit
// Manual submit should work after failed auto-submit
await expect(batch.submit()).resolves.toBeDefined()
})
})
describe('Partial batch failure during auto-submit', () => {
it('should emit error event when some items fail during auto-submit', async () => {
// Create adapter that fails some items
const partialAdapter: BatchAdapter = {
async submit(items: BatchItem[]): Promise<BatchSubmitResult> {
const results: BatchResult[] = items.map((item, i) => ({
id: item.id,
customId: item.id,
status: i % 2 === 0 ? 'completed' : 'failed',
result: i % 2 === 0 ? `Result for ${item.prompt}` : undefined,
error: i % 2 === 1 ? 'Processing failed' : undefined,
}))
return {
job: {
id: 'batch_partial',
provider: 'openai',
status: 'completed',
totalItems: items.length,
completedItems: results.filter((r) => r.status === 'completed').length,
failedItems: results.filter((r) => r.status === 'failed').length,
createdAt: new Date(),
},
completion: Promise.resolve(results),
}
},
async getStatus(batchId: string): Promise<BatchJob> {
return {
id: batchId,
provider: 'openai',
status: 'completed',
totalItems: 4,
completedItems: 2,
failedItems: 2,
createdAt: new Date(),
}
},
async cancel(): Promise<void> {},
async getResults(): Promise<BatchResult[]> {
return []
},
async waitForCompletion(): Promise<BatchResult[]> {
return []
},
}
registerBatchAdapter('openai', partialAdapter)
const partialFailureHandler = vi.fn()
const batch = createBatch({
provider: 'openai',
autoSubmit: true,
maxItems: 4,
})
// FAILING: There should be a way to subscribe to partial failure events
// This tests that callers can be notified when some items fail
if ('on' in batch) {
;(
batch as BatchQueue & {
on: (event: string, handler: (results: BatchResult[]) => void) => void
}
).on('partial-failure', partialFailureHandler)
}
batch.add('prompt 1')
batch.add('prompt 2')
batch.add('prompt 3')
batch.add('prompt 4') // Triggers auto-submit
// Wait for auto-submit to complete
await new Promise((resolve) => setTimeout(resolve, 100))
// FAILING: Partial failure handler should be called with failed items
expect(partialFailureHandler).toHaveBeenCalled()
expect(partialFailureHandler).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ status: 'failed', error: 'Processing failed' }),
])
)
})
it('should provide aggregated error info after partial auto-submit failure', async () => {
const partialAdapter: BatchAdapter = {
async submit(items: BatchItem[]): Promise<BatchSubmitResult> {
const results: BatchResult[] = items.map((item, i) => ({
id: item.id,
customId: item.id,
status: i % 2 === 0 ? 'completed' : 'failed',
result: i % 2 === 0 ? `Result for ${item.prompt}` : undefined,
error: i % 2 === 1 ? 'Processing failed' : undefined,
}))
return {
job: {
id: 'batch_partial',
provider: 'openai',
status: 'completed',
totalItems: items.length,
completedItems: results.filter((r) => r.status === 'completed').length,
failedItems: results.filter((r) => r.status === 'failed').length,
createdAt: new Date(),
},
completion: Promise.resolve(results),
}
},
async getStatus(batchId: string): Promise<BatchJob> {
return {
id: batchId,
provider: 'openai',
status: 'completed',
totalItems: 4,
completedItems: 2,
failedItems: 2,
createdAt: new Date(),
}
},
async cancel(): Promise<void> {},
async getResults(): Promise<BatchResult[]> {
return []
},
async waitForCompletion(): Promise<BatchResult[]> {
return []
},
}
registerBatchAdapter('openai', partialAdapter)
const batch = createBatch({
provider: 'openai',
autoSubmit: true,
maxItems: 4,
})
batch.add('prompt 1')
batch.add('prompt 2')
batch.add('prompt 3')
batch.add('prompt 4') // Triggers auto-submit
await new Promise((resolve) => setTimeout(resolve, 100))
// FAILING: There should be a way to get failure summary
const failedItems = (
batch as BatchQueue & { getFailedItems?: () => BatchItem[] }
).getFailedItems?.()
expect(failedItems).toBeDefined()
expect(failedItems?.length).toBe(2)
})
})
describe('Console.error verification (current behavior)', () => {
it('verifies errors are currently only logged, not propagated', async () => {
const testError = new Error('Test submission error')
registerBatchAdapter('openai', createFailingAdapter(testError))
const batch = createBatch({
provider: 'openai',
autoSubmit: true,
maxItems: 2,
})
batch.add('prompt 1')
batch.add('prompt 2') // Triggers auto-submit
await new Promise((resolve) => setTimeout(resolve, 100))
// This passes - errors ARE logged
// Logger calls with format: 'Batch auto-submit failed:', error
expect(consoleErrorSpy).toHaveBeenCalledWith('Batch auto-submit failed:', testError)
// But there's no other way to access the error
// - No error event emitted
// - No error property on batch
// - No way to await the auto-submit
// - Items remain in 'pending' state
const items = batch.getItems()
// FAILING: Items should not remain pending after failed submission
expect(items[0].status).not.toBe('pending')
})
})
})
describe('Suggested API improvements', () => {
it('documents expected error handling API', () => {
// This test documents what the error handling API SHOULD look like
// All these assertions will fail, showing the gaps
const batch = createBatch({
provider: 'openai',
autoSubmit: true,
maxItems: 5,
})
// 1. Event-based error handling
expect('on' in batch).toBe(true)
expect(typeof (batch as unknown as { on?: unknown }).on).toBe('function')
// 2. Promise-based error handling
expect('awaitAutoSubmit' in batch).toBe(true)
expect(typeof (batch as unknown as { awaitAutoSubmit?: unknown }).awaitAutoSubmit).toBe(
'function'
)
// 3. Error state inspection
expect('submissionError' in batch).toBe(true)
expect('hasSubmissionError' in batch).toBe(true)
// 4. Retry capability
expect('retry' in batch).toBe(true)
expect(typeof (batch as unknown as { retry?: unknown }).retry).toBe('function')
})
})