Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion docs/websocket-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,33 @@ This would reduce the window of stale data but is not currently implemented.
| Instance heartbeat TTL | 60s | Dead instance detection |
| Session members TTL | 4 hours | Matches session TTL |

### Error Handling and Filtering

The WebSocket client includes error handling for common connection issues:

**Origin/CORS Errors**

Some browsers (especially Safari/WebKit on iOS) throw cryptic "invalid origin" errors during WebSocket handshake. These can be caused by:
- Browser privacy/tracking protection blocking WebSocket connections
- Network issues during the handshake phase
- Server CORS configuration mismatches

The client:
1. Wraps WebSocket with early error handlers (`graphql-client.ts`)
2. Detects origin-related errors using patterns in `websocket-errors.ts`
3. Skips retry attempts for origin errors (they won't resolve without configuration changes)
4. Filters these errors from Sentry to reduce noise (`instrumentation-client.ts`)

**Filtered Error Patterns**

The following patterns are filtered from Sentry (defined in `packages/web/app/lib/websocket-errors.ts`):
- `invalid origin` - Browser origin validation error
- `origin not allowed` - Server CORS rejection
- `websocket is already in closing` - Normal lifecycle state
- `graphql subscription` - Expected subscription lifecycle events

Generic errors like "failed to fetch" or "connection closed" are NOT filtered, as they may indicate legitimate issues that need investigation.

---

## Related Files
Expand All @@ -929,10 +956,12 @@ This would reduce the window of stale data but is not currently implemented.

### Frontend

- `packages/web/app/components/graphql-queue/graphql-client.ts` - WebSocket client
- `packages/web/app/components/graphql-queue/graphql-client.ts` - WebSocket client with error handling
- `packages/web/app/components/graphql-queue/use-queue-session.ts` - Session hook
- `packages/web/app/components/persistent-session/persistent-session-context.tsx` - Root-level session management
- `packages/web/app/components/graphql-queue/QueueContext.tsx` - Queue state context
- `packages/web/app/lib/websocket-errors.ts` - WebSocket error detection utilities
- `packages/web/instrumentation-client.ts` - Sentry error filtering

### Shared

Expand Down
115 changes: 113 additions & 2 deletions packages/web/app/components/graphql-queue/graphql-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createClient, Client, Sink } from 'graphql-ws';
import { isOriginError } from '@/app/lib/websocket-errors';

export type { Client };

Expand All @@ -12,6 +13,72 @@ const BACKOFF_MULTIPLIER = 2; // Double the delay each retry

let clientCounter = 0;

/**
* Create a wrapped WebSocket class that provides better error handling
* for connection failures, especially origin/CORS related issues.
*
* This is needed because some browsers (especially Safari/WebKit on iOS)
* throw cryptic "invalid origin" errors during WebSocket handshake that
* can become unhandled rejections.
*/
function createWrappedWebSocket(clientId: number): typeof WebSocket {
return class WrappedWebSocket extends WebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
try {
super(url, protocols);

// Add an early error handler to catch connection failures
// before graphql-ws sets up its handlers
const earlyErrorHandler = (event: Event) => {
const errorEvent = event as ErrorEvent;
const errorMessage = errorEvent.message || 'Unknown WebSocket error';

if (DEBUG) {
console.log(`[GraphQL] Client #${clientId} early WebSocket error:`, errorMessage);
}

// Log origin-related errors with more context
if (isOriginError(errorMessage)) {
console.warn(
`[GraphQL] Client #${clientId} WebSocket connection failed due to origin validation. ` +
`This may be caused by browser privacy settings, network issues, or CORS configuration. ` +
`Error: ${errorMessage}`
);
}

// Remove this handler after it fires once - graphql-ws will handle subsequent errors
this.removeEventListener('error', earlyErrorHandler);
};

this.addEventListener('error', earlyErrorHandler);

// Also handle immediate close with reason
const earlyCloseHandler = (event: CloseEvent) => {
if (event.code !== 1000 && event.reason) {
if (DEBUG) {
console.log(`[GraphQL] Client #${clientId} early WebSocket close:`, event.code, event.reason);
}

if (isOriginError(event.reason)) {
console.warn(
`[GraphQL] Client #${clientId} WebSocket connection rejected. ` +
`Reason: ${event.reason} (code: ${event.code})`
);
}
}
this.removeEventListener('close', earlyCloseHandler);
};

this.addEventListener('close', earlyCloseHandler);
} catch (error) {
// WebSocket constructor can throw for invalid URLs
console.error(`[GraphQL] Client #${clientId} WebSocket construction failed:`, error);
throw error;
}
}
};
}

// Cache for parsed operation names to avoid regex on every call
const operationNameCache = new WeakMap<{ query: string }, string>();

Expand Down Expand Up @@ -64,10 +131,26 @@ export function createGraphQLClient(

let hasConnectedOnce = false;

// Create wrapped WebSocket for better error handling
const WrappedWebSocket = createWrappedWebSocket(clientId);

const client = createClient({
url,
// Use our wrapped WebSocket for better error handling
webSocketImpl: WrappedWebSocket,
retryAttempts: 10, // More attempts with exponential backoff
shouldRetry: () => true,
shouldRetry: (errOrCloseEvent) => {
// Don't retry on origin/CORS errors - these won't resolve without config changes
if (errOrCloseEvent instanceof Error && isOriginError(errOrCloseEvent.message)) {
console.warn(`[GraphQL] Client #${clientId} not retrying due to origin error`);
return false;
}
if (errOrCloseEvent instanceof CloseEvent && errOrCloseEvent.reason && isOriginError(errOrCloseEvent.reason)) {
console.warn(`[GraphQL] Client #${clientId} not retrying due to origin rejection`);
return false;
}
return true;
},
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
retryWait: async (retryCount) => {
const delay = Math.min(
Expand All @@ -94,9 +177,37 @@ export function createGraphQLClient(
},
closed: (event) => {
if (DEBUG) console.log(`[GraphQL] Client #${clientId} closed`, event);

// Log meaningful close events
if (event && typeof event === 'object') {
const closeEvent = event as CloseEvent;
if (closeEvent.code !== 1000 && closeEvent.reason) {
if (isOriginError(closeEvent.reason)) {
console.error(
`[GraphQL] Client #${clientId} connection closed due to origin validation failure. ` +
`This usually indicates a CORS configuration issue or browser privacy settings blocking the connection.`
);
}
}
}
},
error: (error) => {
if (DEBUG) console.log(`[GraphQL] Client #${clientId} error`, error);
// Extract error message for better logging
const errorMessage = error instanceof Error ? error.message : String(error);

if (isOriginError(errorMessage)) {
// Provide more context for origin-related errors
console.error(
`[GraphQL] Client #${clientId} origin validation error: ${errorMessage}. ` +
`This can be caused by:\n` +
` 1. Browser privacy/tracking protection blocking the WebSocket connection\n` +
` 2. Network issues during the WebSocket handshake\n` +
` 3. Server CORS configuration not allowing this origin\n` +
`Please try refreshing the page or check your browser settings.`
);
} else if (DEBUG) {
console.log(`[GraphQL] Client #${clientId} error`, error);
}
},
},
}) as ExtendedClient;
Expand Down
124 changes: 124 additions & 0 deletions packages/web/app/lib/__tests__/websocket-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest';
import {
isOriginError,
isWebSocketLifecycleError,
shouldFilterFromSentry,
ORIGIN_ERROR_PATTERNS,
WEBSOCKET_LIFECYCLE_PATTERNS,
} from '../websocket-errors';

describe('websocket-errors', () => {
describe('isOriginError', () => {
it('should detect "invalid origin" error', () => {
expect(isOriginError('invalid origin')).toBe(true);
expect(isOriginError('Error: invalid origin')).toBe(true);
expect(isOriginError('Invalid Origin')).toBe(true);
});

it('should detect "origin not allowed" error', () => {
expect(isOriginError('origin not allowed')).toBe(true);
expect(isOriginError('Origin not allowed')).toBe(true);
expect(isOriginError('Error: Origin not allowed for this request')).toBe(true);
});

it('should not match generic errors', () => {
expect(isOriginError('Network error')).toBe(false);
expect(isOriginError('Failed to fetch')).toBe(false);
expect(isOriginError('Connection timeout')).toBe(false);
expect(isOriginError('original')).toBe(false); // Avoid false positives
});

it('should be case insensitive', () => {
expect(isOriginError('INVALID ORIGIN')).toBe(true);
expect(isOriginError('Invalid Origin')).toBe(true);
expect(isOriginError('invalid ORIGIN')).toBe(true);
});

it('should handle edge cases safely', () => {
expect(isOriginError('')).toBe(false);
// TypeScript enforces string type, but test runtime safety
expect(isOriginError(undefined as unknown as string)).toBe(false);
expect(isOriginError(null as unknown as string)).toBe(false);
});
});

describe('isWebSocketLifecycleError', () => {
it('should detect WebSocket closing state errors', () => {
expect(isWebSocketLifecycleError('WebSocket is already in CLOSING state')).toBe(true);
expect(isWebSocketLifecycleError('websocket is already in closing')).toBe(true);
});

it('should detect GraphQL subscription errors', () => {
expect(isWebSocketLifecycleError('GraphQL subscription error')).toBe(true);
expect(isWebSocketLifecycleError('graphql subscription terminated')).toBe(true);
});

it('should NOT match generic WebSocket connection errors (intentionally specific)', () => {
// These are intentionally NOT matched to avoid suppressing legitimate errors
expect(isWebSocketLifecycleError('WebSocket connection to wss://example.com failed')).toBe(false);
expect(isWebSocketLifecycleError('Connection refused')).toBe(false);
});

it('should not match generic errors', () => {
expect(isWebSocketLifecycleError('Network error')).toBe(false);
expect(isWebSocketLifecycleError('Failed to fetch')).toBe(false);
});

it('should handle edge cases safely', () => {
expect(isWebSocketLifecycleError('')).toBe(false);
expect(isWebSocketLifecycleError(undefined as unknown as string)).toBe(false);
expect(isWebSocketLifecycleError(null as unknown as string)).toBe(false);
});
});

describe('shouldFilterFromSentry', () => {
it('should filter origin errors', () => {
expect(shouldFilterFromSentry('invalid origin')).toBe(true);
expect(shouldFilterFromSentry('Origin not allowed')).toBe(true);
});

it('should filter WebSocket lifecycle errors', () => {
expect(shouldFilterFromSentry('WebSocket is already in CLOSING state')).toBe(true);
expect(shouldFilterFromSentry('graphql subscription error')).toBe(true);
});

it('should NOT filter generic network errors', () => {
// These are legitimate errors that should be reported
expect(shouldFilterFromSentry('Failed to fetch')).toBe(false);
expect(shouldFilterFromSentry('Network error')).toBe(false);
expect(shouldFilterFromSentry('Connection refused')).toBe(false);
expect(shouldFilterFromSentry('connection closed')).toBe(false);
});

it('should NOT filter generic WebSocket errors (to catch legitimate failures)', () => {
expect(shouldFilterFromSentry('WebSocket connection to wss://example.com failed')).toBe(false);
});

it('should NOT filter API errors', () => {
expect(shouldFilterFromSentry('API request failed')).toBe(false);
expect(shouldFilterFromSentry('Server error: 500')).toBe(false);
expect(shouldFilterFromSentry('Unauthorized')).toBe(false);
});

it('should handle edge cases safely', () => {
expect(shouldFilterFromSentry('')).toBe(false);
expect(shouldFilterFromSentry(undefined as unknown as string)).toBe(false);
expect(shouldFilterFromSentry(null as unknown as string)).toBe(false);
});
});

describe('exported constants', () => {
it('should export ORIGIN_ERROR_PATTERNS', () => {
expect(ORIGIN_ERROR_PATTERNS).toContain('invalid origin');
expect(ORIGIN_ERROR_PATTERNS).toContain('origin not allowed');
});

it('should export WEBSOCKET_LIFECYCLE_PATTERNS with specific patterns only', () => {
expect(WEBSOCKET_LIFECYCLE_PATTERNS).toContain('websocket is already in closing');
expect(WEBSOCKET_LIFECYCLE_PATTERNS).toContain('graphql subscription');
// Should NOT contain overly broad patterns
expect(WEBSOCKET_LIFECYCLE_PATTERNS).not.toContain('websocket connection to');
expect(WEBSOCKET_LIFECYCLE_PATTERNS).not.toContain('connection closed');
});
});
});
79 changes: 79 additions & 0 deletions packages/web/app/lib/websocket-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* WebSocket error detection utilities
*
* Shared utilities for detecting and handling WebSocket connection errors,
* particularly origin/CORS related issues that occur on some browsers.
*/

/**
* Known WebSocket connection error messages that indicate origin/CORS issues.
* These are browser-specific error messages that can occur during WebSocket handshake.
*/
export const ORIGIN_ERROR_PATTERNS = [
'invalid origin',
'origin not allowed',
] as const;

/**
* Check if an error message indicates an origin/CORS issue.
* These errors are typically caused by:
* - Browser privacy/tracking protection blocking WebSocket connections
* - Server CORS configuration not allowing the origin
* - Network issues during WebSocket handshake on some browsers
*
* Returns false for empty/null/undefined input to handle edge cases safely.
*/
export function isOriginError(errorMessage: string): boolean {
if (!errorMessage) {
return false;
}
const lowerMessage = errorMessage.toLowerCase();
return ORIGIN_ERROR_PATTERNS.some(pattern => lowerMessage.includes(pattern));
}

/**
* Additional patterns that indicate WebSocket-specific connection errors
* that are expected during normal operation and shouldn't be reported to Sentry.
*
* These patterns are intentionally specific to avoid filtering legitimate errors:
* - 'websocket is already in closing' - WebSocket state transition (not an error)
* - 'graphql subscription' - Expected subscription lifecycle events
*
* Note: We intentionally do NOT include generic patterns like:
* - 'websocket connection to' - too broad, would match legitimate connection failures
* - 'connection closed' - too generic, could be API or other connection errors
* - 'failed to fetch' - too generic, would hide real API failures
*/
export const WEBSOCKET_LIFECYCLE_PATTERNS = [
'websocket is already in closing',
'graphql subscription',
] as const;

/**
* Check if an error is a known WebSocket lifecycle error that shouldn't be
* reported to Sentry. This is more specific than isOriginError and only
* matches errors that are clearly WebSocket-related lifecycle events.
*
* Returns false for empty/null/undefined input to handle edge cases safely.
*/
export function isWebSocketLifecycleError(errorMessage: string): boolean {
if (!errorMessage) {
return false;
}
const lowerMessage = errorMessage.toLowerCase();
return WEBSOCKET_LIFECYCLE_PATTERNS.some(pattern => lowerMessage.includes(pattern));
}

/**
* Combined check for errors that should be filtered from Sentry.
* Only filters errors that are clearly WebSocket/origin related,
* not generic network errors.
*
* Returns false for empty/null/undefined input to handle edge cases safely.
*/
export function shouldFilterFromSentry(errorMessage: string): boolean {
if (!errorMessage) {
return false;
}
return isOriginError(errorMessage) || isWebSocketLifecycleError(errorMessage);
}
Loading
Loading