Skip to content

Commit

Permalink
classify errors
Browse files Browse the repository at this point in the history
  • Loading branch information
21e8 committed Dec 7, 2024
1 parent f971097 commit b8e9afb
Show file tree
Hide file tree
Showing 6 changed files with 224 additions and 31 deletions.
49 changes: 31 additions & 18 deletions jest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,38 @@
// Make this a module by adding an export
export {};

const mockFetch = jest.fn(async () => ({
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers(),
redirected: false,
type: 'basic' as ResponseType,
url: 'https://mock.url',
json: async () => ({}),
text: async () => '',
clone: () => ({} as Response),
body: null,
bodyUsed: false,
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
})) as jest.Mock<Promise<Response>>;
class MockResponse implements Response {
readonly headers: Headers;
readonly ok: boolean;
readonly redirected: boolean;
readonly status: number;
readonly statusText: string;
readonly type: "basic" | "cors" | "default" | "error" | "opaque" | "opaqueredirect";
readonly url: string;
readonly body: ReadableStream | null;
readonly bodyUsed: boolean;

global.fetch = mockFetch;
constructor(private data: any = {}) {
this.headers = new Headers();
this.ok = true;
this.redirected = false;
this.status = 200;
this.statusText = 'OK';
this.type = 'basic';
this.url = '';
this.body = null;
this.bodyUsed = false;
}

json() { return Promise.resolve(this.data); }
text() { return Promise.resolve(''); }
blob() { return Promise.resolve(new Blob([])); }
arrayBuffer() { return Promise.resolve(new ArrayBuffer(0)); }
formData() { return Promise.resolve(new FormData()); }
clone(): Response { return new MockResponse(this.data); }
}

(global.fetch as any) = jest.fn(() => Promise.resolve(new MockResponse()));

// Reset all mocks before each test
beforeEach(() => {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "0xalice-tgram-bot",
"version": "0.2.11",
"version": "0.2.12",
"description": "Batched Telegram notification bot for 0xAlice",
"type": "module",
"main": "./dist/cjs/index.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createTelegramProcessor } from '../processors/telegram';
import type { Message, TelegramConfig } from '../types';
import { createTelegramProcessor } from '../../processors/telegram';
import type { Message, TelegramConfig } from '../../types';

describe('TelegramProcessor', () => {
const defaultConfig: TelegramConfig = {
Expand All @@ -8,9 +8,14 @@ describe('TelegramProcessor', () => {
};

beforeEach(() => {
// Mock the native fetch
(global.fetch as jest.Mock) = jest.fn(() =>
Promise.resolve({ ok: true })
// Updated mock implementation
(global.fetch as jest.Mock).mockImplementation(() =>
Promise.resolve({
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve({})
} as Response)
);
});

Expand Down Expand Up @@ -55,11 +60,14 @@ describe('TelegramProcessor', () => {
});

it('should throw error on failed API response', async () => {
// Updated mock implementation for failed response
(global.fetch as jest.Mock).mockImplementationOnce(() =>
Promise.resolve({
ok: false,
statusText: 'Bad Request'
})
status: 400,
statusText: 'Bad Request',
json: () => Promise.resolve({})
} as Response)
);

const processor = createTelegramProcessor(defaultConfig);
Expand Down
40 changes: 35 additions & 5 deletions src/processors/telegram.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { Message, MessageProcessor, TelegramConfig } from '../types';
import { classifyError } from '../utils/errorClassifier';
// import fetch from 'node-fetch';

export function createTelegramProcessor(config: TelegramConfig): MessageProcessor {
export function createTelegramProcessor(
config: TelegramConfig
): MessageProcessor {
const { botToken, chatId, development = false } = config;
const baseUrl = `https://api.telegram.org/bot${botToken}`;

Expand All @@ -10,21 +13,48 @@ export function createTelegramProcessor(config: TelegramConfig): MessageProcesso
console.log('[Telegram] Would send messages:', messages);
return;
}

if (!messages.length) {
return;
}

const text = messages
.map((msg) => `[${msg.level.toUpperCase()}] ${msg.text}`)
const formattedMessages = messages
.map((msg) => {
const prefix = msg.level.toUpperCase();
let text = `[${prefix}] ${msg.text}`;

if (msg.level === 'error' && msg.error) {
const classified = classifyError(msg.error);

// Skip throttled errors
if (classified.shouldThrottle) {
if (classified.nextAllowedTimestamp) {
const waitMinutes = Math.ceil(
(classified.nextAllowedTimestamp - Date.now()) / 60000
);
text += `\n[THROTTLED] Similar errors suppressed for ${waitMinutes} minutes`;
}
return null;
}

text += `\nCategory: ${classified.category}`;
text += `\nSeverity: ${classified.severity}`;
if (classified.details) {
text += `\nDetails: ${JSON.stringify(classified.details)}`;
}
}

return text;
})
.filter(Boolean) // Remove null entries from throttled errors
.join('\n');

const response = await fetch(`${baseUrl}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text,
text: formattedMessages,
parse_mode: 'HTML',
}),
});
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type Message = {
chatId: string;
text: string;
level: NotificationLevel;
error?: Error | string;
};

export type BatcherConfig = {
Expand Down
141 changes: 141 additions & 0 deletions src/utils/errorClassifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
type ErrorPattern = {
pattern: RegExp;
category: string;
severity: 'low' | 'medium' | 'high';
backpressure?: {
windowMs: number;
maxErrors: number;
cooldownMs: number;
};
};

// Default patterns
const DEFAULT_ERROR_PATTERNS: ErrorPattern[] = [
{
pattern: /duplicate key value violates unique constraint/i,
category: 'DATABASE_CONSTRAINT_VIOLATION',
severity: 'medium',
backpressure: {
windowMs: 60000,
maxErrors: 5,
cooldownMs: 300000
}
},
{
pattern: /connection refused|connection timeout/i,
category: 'CONNECTION_ERROR',
severity: 'high',
backpressure: {
windowMs: 30000,
maxErrors: 3,
cooldownMs: 60000
}
},
{
pattern: /invalid signature|unauthorized/i,
category: 'AUTH_ERROR',
severity: 'high'
}
];

// Store custom patterns
let customPatterns: ErrorPattern[] = [];

export function addErrorPatterns(patterns: ErrorPattern[]): void {
customPatterns = customPatterns.concat(patterns);
}

export function resetErrorPatterns(): void {
customPatterns = [];
}

// Get all patterns (custom patterns take precedence)
function getPatterns(): ErrorPattern[] {
return [...customPatterns, ...DEFAULT_ERROR_PATTERNS];
}

// Track error occurrences
const errorTracker = new Map<string, {
timestamps: number[];
cooldownUntil?: number;
}>();

type ClassifiedError = {
originalMessage: string;
category: string;
severity: 'low' | 'medium' | 'high';
details?: Record<string, string>;
shouldThrottle: boolean;
nextAllowedTimestamp?: number;
};

export function classifyError(error: Error | string): ClassifiedError {
const message = error instanceof Error ? error.message : error;
const now = Date.now();

for (const { pattern, category, severity, backpressure } of getPatterns()) {
if (pattern.test(message)) {
const details: Record<string, string> = {};

// Extract specific details based on category
if (category === 'DATABASE_CONSTRAINT_VIOLATION') {
const constraint = message.match(/constraint "([^"]+)"/)?.[1];
if (constraint) {
details.constraint = constraint;
}
}

// Handle backpressure if configured
let shouldThrottle = false;
let nextAllowedTimestamp: number | undefined;

if (backpressure) {
const tracker = errorTracker.get(category) || { timestamps: [] };

// Check if in cooldown
if (tracker.cooldownUntil && now < tracker.cooldownUntil) {
shouldThrottle = true;
nextAllowedTimestamp = tracker.cooldownUntil;
} else {
// Clean old timestamps
tracker.timestamps = tracker.timestamps.filter(
t => t > now - backpressure.windowMs
);

// Add current timestamp
tracker.timestamps.push(now);

// Check if threshold exceeded
if (tracker.timestamps.length >= backpressure.maxErrors) {
shouldThrottle = true;
tracker.cooldownUntil = now + backpressure.cooldownMs;
nextAllowedTimestamp = tracker.cooldownUntil;
}
}

errorTracker.set(category, tracker);
}

return {
originalMessage: message,
category,
severity,
details: Object.keys(details).length > 0 ? details : undefined,
shouldThrottle,
nextAllowedTimestamp
};
}
}

return {
originalMessage: message,
category: 'UNKNOWN_ERROR',
severity: 'medium',
shouldThrottle: false
};
}

// Optional: Add method to clear error tracking
export function clearErrorTracking(): void {
errorTracker.clear();
}

0 comments on commit b8e9afb

Please sign in to comment.