feat: implement stability fixes and enhance error handling

This commit is contained in:
Draculabo committed 2026-01-16 02:51:29 +08:00
1 parent b0ec96d4a6
commit 5772e2e90e
17 files changed
+1161 -108

No files matched your search

+92
View File
@@ -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"
});
});
+59
View File
@@ -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%
});
});
+48
View File
@@ -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();
});
});
});
+61
View File
@@ -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: ');
});
});
+61
View File
@@ -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);
});
});
});
+90
View File
@@ -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');
});
});