feat: implement stability fixes and enhance error handling
This commit is contained in:
1 parent
b0ec96d4a6
commit
5772e2e90e
17 files changed
+1161
-108
No files matched your search
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
// Mock dependencies
|
||||
const mockTokenManager = { getNextToken: vi.fn(), markAsRateLimited: vi.fn() };
|
||||
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
|
||||
|
||||
// Subclass to access private method
|
||||
class TestableProxyService extends ProxyService {
|
||||
constructor() {
|
||||
super(mockTokenManager as any, mockGeminiClient as any);
|
||||
}
|
||||
|
||||
public testProcessStream(stream: any, model: string = 'model'): Observable<string> {
|
||||
// Access private method using type assertion
|
||||
return (this as any).processAnthropicInternalStream(stream, model);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProxyService Empty Stream Retry Logic', () => {
|
||||
it('should emit error when stream ends without data', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
|
||||
const resultObservable = service.testProcessStream(stream);
|
||||
|
||||
let errorReceived: Error | undefined;
|
||||
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resultObservable.subscribe({
|
||||
next: () => {},
|
||||
error: (err) => {
|
||||
errorReceived = err;
|
||||
resolve();
|
||||
},
|
||||
complete: () => resolve(),
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate empty stream: straight to end
|
||||
setTimeout(() => stream.emit('end'), 10);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(errorReceived).toBeDefined();
|
||||
expect(errorReceived?.message).toBe('Empty response stream');
|
||||
});
|
||||
|
||||
it('should NOT emit error when stream has data', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
|
||||
const resultObservable = service.testProcessStream(stream);
|
||||
|
||||
let errorReceived: Error | undefined;
|
||||
const receivedChunks: string[] = [];
|
||||
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resultObservable.subscribe({
|
||||
next: (c) => receivedChunks.push(c),
|
||||
error: (err) => {
|
||||
errorReceived = err;
|
||||
resolve();
|
||||
},
|
||||
complete: () => resolve(),
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate valid data stream
|
||||
setTimeout(() => {
|
||||
const validJson = JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'hello' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
});
|
||||
stream.emit('data', Buffer.from(`data: ${validJson}\n\n`));
|
||||
stream.emit('end');
|
||||
}, 10);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(errorReceived).toBeUndefined();
|
||||
// It should produce chunks (though exact number depends on mapper logic, at least it shouldn't error)
|
||||
// Actually our mapper might produce "message_start", "content_block_start" etc.
|
||||
// We just care that it didn't error with "Empty response stream"
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { applyJitter, calculateRetryDelay } from '../../lib/antigravity/retry-utils';
|
||||
|
||||
describe('applyJitter', () => {
|
||||
it('should return values within ±20% range', () => {
|
||||
const baseDelay = 1000;
|
||||
const results: number[] = [];
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
results.push(applyJitter(baseDelay));
|
||||
}
|
||||
|
||||
// All values should be within 800-1200 range (±20%)
|
||||
results.forEach((r) => {
|
||||
expect(r).toBeGreaterThanOrEqual(800);
|
||||
expect(r).toBeLessThanOrEqual(1200);
|
||||
});
|
||||
});
|
||||
|
||||
it('should produce different values (randomness)', () => {
|
||||
const results = new Set<number>();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
results.add(applyJitter(1000));
|
||||
}
|
||||
// 20 calls should produce multiple distinct values
|
||||
expect(results.size).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it('should guarantee minimum value of 1 for small inputs', () => {
|
||||
expect(applyJitter(1)).toBeGreaterThanOrEqual(1);
|
||||
expect(applyJitter(0)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateRetryDelay', () => {
|
||||
it('should implement exponential backoff', () => {
|
||||
// First attempt (index 0): 1s ± 20% = 800-1200ms
|
||||
const delay0 = calculateRetryDelay(0);
|
||||
expect(delay0).toBeGreaterThanOrEqual(800);
|
||||
expect(delay0).toBeLessThanOrEqual(1200);
|
||||
|
||||
// Second attempt (index 1): 2s ± 20% = 1600-2400ms
|
||||
const delay1 = calculateRetryDelay(1);
|
||||
expect(delay1).toBeGreaterThanOrEqual(1600);
|
||||
expect(delay1).toBeLessThanOrEqual(2400);
|
||||
});
|
||||
|
||||
it('should have maximum delay limit', () => {
|
||||
// Even with high retry count, should not exceed 30s + 20% jitter = 36s
|
||||
const delay = calculateRetryDelay(10);
|
||||
expect(delay).toBeLessThanOrEqual(36000);
|
||||
});
|
||||
|
||||
it('should accept custom base delay', () => {
|
||||
const delay = calculateRetryDelay(0, 500);
|
||||
expect(delay).toBeGreaterThanOrEqual(400); // 500 - 20%
|
||||
expect(delay).toBeLessThanOrEqual(600); // 500 + 20%
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decodeSignature } from '../../lib/antigravity/signature-utils';
|
||||
|
||||
describe('decodeSignature', () => {
|
||||
describe('Base64 decoding', () => {
|
||||
it('should correctly decode valid Base64 signature', () => {
|
||||
const original = 'valid-thinking-signature-12345';
|
||||
const base64 = Buffer.from(original).toString('base64');
|
||||
expect(decodeSignature(base64)).toBe(original);
|
||||
});
|
||||
|
||||
it('should decode signature with special characters', () => {
|
||||
const original = 'sig+with/special=chars==';
|
||||
const base64 = Buffer.from(original).toString('base64');
|
||||
expect(decodeSignature(base64)).toBe(original);
|
||||
});
|
||||
|
||||
it('should decode UTF-8 Chinese signature', () => {
|
||||
const original = '思考签名-测试';
|
||||
const base64 = Buffer.from(original).toString('base64');
|
||||
expect(decodeSignature(base64)).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-Base64 passthrough', () => {
|
||||
it('should return non-Base64 strings as-is', () => {
|
||||
expect(decodeSignature('plain-text-signature')).toBe('plain-text-signature');
|
||||
});
|
||||
|
||||
it('should handle strings with invalid Base64 characters', () => {
|
||||
expect(decodeSignature('not!valid@base64#')).toBe('not!valid@base64#');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty value handling', () => {
|
||||
it('should return undefined for null input', () => {
|
||||
expect(decodeSignature(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for undefined input', () => {
|
||||
expect(decodeSignature(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for empty string', () => {
|
||||
expect(decodeSignature('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyStreamError, formatErrorForSSE } from '../../lib/antigravity/stream-error-utils';
|
||||
|
||||
describe('classifyStreamError', () => {
|
||||
it('should identify timeout errors', () => {
|
||||
const error = new Error('timeout of 30000ms exceeded');
|
||||
const { type, message } = classifyStreamError(error);
|
||||
expect(type).toBe('timeout_error');
|
||||
expect(message).toContain('timed out');
|
||||
});
|
||||
|
||||
it('should identify connection errors', () => {
|
||||
const error = new Error('connect ECONNREFUSED 127.0.0.1:8080');
|
||||
const { type, message } = classifyStreamError(error);
|
||||
expect(type).toBe('connection_error');
|
||||
expect(message).toContain('connect');
|
||||
});
|
||||
|
||||
it('should identify network DNS errors', () => {
|
||||
const error = new Error('getaddrinfo ENOTFOUND googleapis.com');
|
||||
const { type, message } = classifyStreamError(error);
|
||||
expect(type).toBe('network_error');
|
||||
expect(message).toContain('Network');
|
||||
});
|
||||
|
||||
it('should identify socket errors', () => {
|
||||
const error = new Error('socket hang up');
|
||||
const { type, message } = classifyStreamError(error);
|
||||
expect(type).toBe('stream_error');
|
||||
expect(message).toContain('interrupted');
|
||||
});
|
||||
|
||||
it('should return generic message for unknown errors', () => {
|
||||
const error = new Error('some random error');
|
||||
const { type, message } = classifyStreamError(error);
|
||||
expect(type).toBe('unknown_error');
|
||||
expect(message).toBeTruthy();
|
||||
expect(message).toContain('some random error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatErrorForSSE', () => {
|
||||
it('should format as Claude SSE error event', () => {
|
||||
const result = formatErrorForSSE('network_error', 'Network connection failed');
|
||||
expect(result).toContain('event: error');
|
||||
expect(result).toContain('"type":"error"');
|
||||
expect(result).toContain('network_error');
|
||||
expect(result).toContain('Network connection failed');
|
||||
});
|
||||
|
||||
it('should end with newlines', () => {
|
||||
const result = formatErrorForSSE('test', 'test');
|
||||
expect(result.endsWith('\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be valid SSE format', () => {
|
||||
const result = formatErrorForSSE('timeout_error', 'Request timed out');
|
||||
expect(result.startsWith('event: ')).toBe(true);
|
||||
expect(result).toContain('\ndata: ');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { StreamingState } from '../../lib/antigravity/ClaudeStreamingMapper';
|
||||
|
||||
describe('StreamingState', () => {
|
||||
let state: StreamingState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = new StreamingState();
|
||||
});
|
||||
|
||||
describe('handleParseError', () => {
|
||||
it('should return empty array on first error', () => {
|
||||
const chunks = state.handleParseError('invalid json');
|
||||
expect(chunks).toEqual([]);
|
||||
});
|
||||
|
||||
it('should emit error event when error count exceeds 3', () => {
|
||||
// Simulate 4 parse errors
|
||||
state.handleParseError('error 1');
|
||||
state.handleParseError('error 2');
|
||||
state.handleParseError('error 3');
|
||||
const chunks = state.handleParseError('error 4');
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
expect(chunks[0]).toContain('network_error');
|
||||
expect(chunks[0]).toContain('Unstable network');
|
||||
});
|
||||
|
||||
it('should safely close active block on error', () => {
|
||||
// Start a text block first
|
||||
state.startBlock('Text', { type: 'text', text: '' });
|
||||
|
||||
const chunks = state.handleParseError('error during block');
|
||||
|
||||
// Should contain content_block_stop event
|
||||
expect(chunks.some((c) => c.includes('content_block_stop'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetErrorState', () => {
|
||||
it('should reset error counter', () => {
|
||||
state.handleParseError('error 1');
|
||||
state.handleParseError('error 2');
|
||||
state.resetErrorState();
|
||||
|
||||
// After reset, should start counting from 0
|
||||
const chunks = state.handleParseError('error after reset');
|
||||
expect(chunks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getErrorCount', () => {
|
||||
it('should return current error count', () => {
|
||||
expect(state.getErrorCount()).toBe(0);
|
||||
state.handleParseError('error 1');
|
||||
expect(state.getErrorCount()).toBe(1);
|
||||
state.handleParseError('error 2');
|
||||
expect(state.getErrorCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
hasValidSignature,
|
||||
gracefulDegradeThinking,
|
||||
processThinkingBlocks,
|
||||
} from '../../lib/antigravity/thinking-utils';
|
||||
|
||||
describe('hasValidSignature', () => {
|
||||
it('should return true for empty thinking + valid signature', () => {
|
||||
const block = { type: 'thinking' as const, thinking: '', signature: 'valid-signature-long' };
|
||||
expect(hasValidSignature(block)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for content + long signature', () => {
|
||||
const block = {
|
||||
type: 'thinking' as const,
|
||||
thinking: 'some thought',
|
||||
signature: 'long-enough-signature',
|
||||
};
|
||||
expect(hasValidSignature(block)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for content + short signature', () => {
|
||||
const block = { type: 'thinking' as const, thinking: 'some thought', signature: 'short' };
|
||||
expect(hasValidSignature(block)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for content + no signature', () => {
|
||||
const block = { type: 'thinking' as const, thinking: 'some thought', signature: undefined };
|
||||
expect(hasValidSignature(block)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for non-thinking types', () => {
|
||||
const block = { type: 'text' as const, text: 'hello' };
|
||||
expect(hasValidSignature(block)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gracefulDegradeThinking', () => {
|
||||
it('should convert invalid signature thinking to text', () => {
|
||||
const block = { type: 'thinking' as const, thinking: 'important content', signature: 'bad' };
|
||||
const result = gracefulDegradeThinking(block);
|
||||
expect(result?.type).toBe('text');
|
||||
expect((result as any).text).toBe('important content');
|
||||
});
|
||||
|
||||
it('should keep valid thinking block unchanged', () => {
|
||||
const block = {
|
||||
type: 'thinking' as const,
|
||||
thinking: 'content',
|
||||
signature: 'valid-long-signature',
|
||||
};
|
||||
const result = gracefulDegradeThinking(block);
|
||||
expect(result?.type).toBe('thinking');
|
||||
});
|
||||
|
||||
it('should drop empty content + invalid signature block', () => {
|
||||
const block = { type: 'thinking' as const, thinking: '', signature: 'bad' };
|
||||
const result = gracefulDegradeThinking(block);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should keep empty content + valid signature trailing block', () => {
|
||||
const block = { type: 'thinking' as const, thinking: '', signature: 'any-valid-sig' };
|
||||
const result = gracefulDegradeThinking(block);
|
||||
expect(result?.type).toBe('thinking');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processThinkingBlocks', () => {
|
||||
it('should filter out invalid empty thinking blocks', () => {
|
||||
const blocks = [
|
||||
{ type: 'text' as const, text: 'hello' },
|
||||
{ type: 'thinking' as const, thinking: '', signature: 'bad' },
|
||||
{ type: 'text' as const, text: 'world' },
|
||||
];
|
||||
const result = processThinkingBlocks(blocks);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].type).toBe('text');
|
||||
expect(result[1].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should convert content with invalid signature to text', () => {
|
||||
const blocks = [{ type: 'thinking' as const, thinking: 'keep me', signature: 'x' }];
|
||||
const result = processThinkingBlocks(blocks);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].type).toBe('text');
|
||||
expect((result[0] as any).text).toBe('keep me');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user