mirror of
https://github.com/unraid/api.git
synced 2025-12-31 21:49:57 -06:00
fix(connect): rm eager restart on ERROR_RETYING connection status (#1502)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved connection handling to prevent unnecessary reconnection attempts during error retry states, ensuring reconnections only occur on specific failures. * **Tests** * Added comprehensive tests to verify connection recovery, identity-based connection, logout behavior, DDoS prevention, and edge case handling for connection state changes. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,269 @@
|
|||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
|
import { PubSub } from 'graphql-subscriptions';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { MinigraphStatus } from '../config/connect.config.js';
|
||||||
|
import { EVENTS, GRAPHQL_PUBSUB_CHANNEL } from '../helper/nest-tokens.js';
|
||||||
|
import { MothershipConnectionService } from '../mothership-proxy/connection.service.js';
|
||||||
|
import { MothershipController } from '../mothership-proxy/mothership.controller.js';
|
||||||
|
import { MothershipHandler } from '../mothership-proxy/mothership.events.js';
|
||||||
|
|
||||||
|
describe('MothershipHandler - Behavioral Tests', () => {
|
||||||
|
let handler: MothershipHandler;
|
||||||
|
let connectionService: MothershipConnectionService;
|
||||||
|
let mothershipController: MothershipController;
|
||||||
|
let pubSub: PubSub;
|
||||||
|
let eventEmitter: EventEmitter2;
|
||||||
|
|
||||||
|
// Track actual state changes and effects
|
||||||
|
let connectionAttempts: Array<{ timestamp: number; reason: string }> = [];
|
||||||
|
let publishedMessages: Array<{ channel: string; data: any }> = [];
|
||||||
|
let controllerStops: Array<{ timestamp: number; reason?: string }> = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset tracking arrays
|
||||||
|
connectionAttempts = [];
|
||||||
|
publishedMessages = [];
|
||||||
|
controllerStops = [];
|
||||||
|
|
||||||
|
// Create real event emitter for integration testing
|
||||||
|
eventEmitter = new EventEmitter2();
|
||||||
|
|
||||||
|
// Mock connection service with realistic behavior
|
||||||
|
connectionService = {
|
||||||
|
getIdentityState: vi.fn(),
|
||||||
|
getConnectionState: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Mock controller that tracks behavior instead of just method calls
|
||||||
|
mothershipController = {
|
||||||
|
initOrRestart: vi.fn().mockImplementation(() => {
|
||||||
|
connectionAttempts.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
reason: 'initOrRestart called',
|
||||||
|
});
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
stop: vi.fn().mockImplementation(() => {
|
||||||
|
controllerStops.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Mock PubSub that tracks published messages
|
||||||
|
pubSub = {
|
||||||
|
publish: vi.fn().mockImplementation((channel: string, data: any) => {
|
||||||
|
publishedMessages.push({ channel, data });
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
handler = new MothershipHandler(connectionService, mothershipController, pubSub);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Connection Recovery Behavior', () => {
|
||||||
|
it('should attempt reconnection when ping fails', async () => {
|
||||||
|
// Given: Connection is in ping failure state
|
||||||
|
vi.mocked(connectionService.getConnectionState).mockReturnValue({
|
||||||
|
status: MinigraphStatus.PING_FAILURE,
|
||||||
|
error: 'Ping timeout after 3 minutes',
|
||||||
|
});
|
||||||
|
|
||||||
|
// When: Connection status change event occurs
|
||||||
|
await handler.onMothershipConnectionStatusChanged();
|
||||||
|
|
||||||
|
// Then: System should attempt to recover the connection
|
||||||
|
expect(connectionAttempts).toHaveLength(1);
|
||||||
|
expect(connectionAttempts[0].reason).toBe('initOrRestart called');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT interfere with exponential backoff during error retry state', async () => {
|
||||||
|
// Given: Connection is in error retry state (GraphQL client managing backoff)
|
||||||
|
vi.mocked(connectionService.getConnectionState).mockReturnValue({
|
||||||
|
status: MinigraphStatus.ERROR_RETRYING,
|
||||||
|
error: 'Network error',
|
||||||
|
timeout: 20000,
|
||||||
|
timeoutStart: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// When: Connection status change event occurs
|
||||||
|
await handler.onMothershipConnectionStatusChanged();
|
||||||
|
|
||||||
|
// Then: System should NOT interfere with ongoing retry logic
|
||||||
|
expect(connectionAttempts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remain stable during normal connection states', async () => {
|
||||||
|
const stableStates = [MinigraphStatus.CONNECTED, MinigraphStatus.CONNECTING];
|
||||||
|
|
||||||
|
for (const status of stableStates) {
|
||||||
|
// Reset for each test
|
||||||
|
connectionAttempts.length = 0;
|
||||||
|
|
||||||
|
// Given: Connection is in a stable state
|
||||||
|
vi.mocked(connectionService.getConnectionState).mockReturnValue({
|
||||||
|
status,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// When: Connection status change event occurs
|
||||||
|
await handler.onMothershipConnectionStatusChanged();
|
||||||
|
|
||||||
|
// Then: System should not trigger unnecessary reconnection attempts
|
||||||
|
expect(connectionAttempts).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Identity-Based Connection Behavior', () => {
|
||||||
|
it('should establish connection when valid API key becomes available', async () => {
|
||||||
|
// Given: Valid API key is present
|
||||||
|
vi.mocked(connectionService.getIdentityState).mockReturnValue({
|
||||||
|
state: {
|
||||||
|
apiKey: 'valid-unraid-key-12345',
|
||||||
|
unraidVersion: '6.12.0',
|
||||||
|
flashGuid: 'test-flash-guid',
|
||||||
|
apiVersion: '1.0.0',
|
||||||
|
},
|
||||||
|
isLoaded: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// When: Identity changes
|
||||||
|
await handler.onIdentityChanged();
|
||||||
|
|
||||||
|
// Then: System should establish mothership connection
|
||||||
|
expect(connectionAttempts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not attempt connection without valid credentials', async () => {
|
||||||
|
const invalidCredentials = [{ apiKey: undefined }, { apiKey: '' }];
|
||||||
|
|
||||||
|
for (const credentials of invalidCredentials) {
|
||||||
|
// Reset for each test
|
||||||
|
connectionAttempts.length = 0;
|
||||||
|
|
||||||
|
// Given: Invalid or missing API key
|
||||||
|
vi.mocked(connectionService.getIdentityState).mockReturnValue({
|
||||||
|
state: credentials,
|
||||||
|
isLoaded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// When: Identity changes
|
||||||
|
await handler.onIdentityChanged();
|
||||||
|
|
||||||
|
// Then: System should not attempt connection
|
||||||
|
expect(connectionAttempts).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Logout Behavior', () => {
|
||||||
|
it('should properly clean up connections and notify subscribers on logout', async () => {
|
||||||
|
// When: User logs out
|
||||||
|
await handler.logout({ reason: 'User initiated logout' });
|
||||||
|
|
||||||
|
// Then: System should clean up connections
|
||||||
|
expect(controllerStops).toHaveLength(1);
|
||||||
|
|
||||||
|
// And: Subscribers should be notified of empty state
|
||||||
|
expect(publishedMessages).toHaveLength(2);
|
||||||
|
|
||||||
|
const serversMessage = publishedMessages.find(
|
||||||
|
(m) => m.channel === GRAPHQL_PUBSUB_CHANNEL.SERVERS
|
||||||
|
);
|
||||||
|
const ownerMessage = publishedMessages.find(
|
||||||
|
(m) => m.channel === GRAPHQL_PUBSUB_CHANNEL.OWNER
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(serversMessage?.data).toEqual({ servers: [] });
|
||||||
|
expect(ownerMessage?.data).toEqual({
|
||||||
|
owner: { username: 'root', url: '', avatar: '' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle logout gracefully even without explicit reason', async () => {
|
||||||
|
// When: System logout occurs without reason
|
||||||
|
await handler.logout({});
|
||||||
|
|
||||||
|
// Then: Cleanup should still occur properly
|
||||||
|
expect(controllerStops).toHaveLength(1);
|
||||||
|
expect(publishedMessages).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DDoS Prevention Behavior', () => {
|
||||||
|
it('should demonstrate exponential backoff is respected during network errors', async () => {
|
||||||
|
// Given: Multiple rapid network errors occur
|
||||||
|
const errorStates = [
|
||||||
|
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 1' },
|
||||||
|
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 2' },
|
||||||
|
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// When: Rapid error retry states occur
|
||||||
|
for (const state of errorStates) {
|
||||||
|
vi.mocked(connectionService.getConnectionState).mockReturnValue(state);
|
||||||
|
await handler.onMothershipConnectionStatusChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then: No linear retry attempts should be made (respecting exponential backoff)
|
||||||
|
expect(connectionAttempts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should differentiate between network errors and ping failures', async () => {
|
||||||
|
// Given: Network error followed by ping failure
|
||||||
|
vi.mocked(connectionService.getConnectionState).mockReturnValue({
|
||||||
|
status: MinigraphStatus.ERROR_RETRYING,
|
||||||
|
error: 'Network error',
|
||||||
|
});
|
||||||
|
|
||||||
|
// When: Network error occurs
|
||||||
|
await handler.onMothershipConnectionStatusChanged();
|
||||||
|
|
||||||
|
// Then: No immediate reconnection attempt
|
||||||
|
expect(connectionAttempts).toHaveLength(0);
|
||||||
|
|
||||||
|
// Given: Ping failure occurs (different issue)
|
||||||
|
vi.mocked(connectionService.getConnectionState).mockReturnValue({
|
||||||
|
status: MinigraphStatus.PING_FAILURE,
|
||||||
|
error: 'Ping timeout',
|
||||||
|
});
|
||||||
|
|
||||||
|
// When: Ping failure occurs
|
||||||
|
await handler.onMothershipConnectionStatusChanged();
|
||||||
|
|
||||||
|
// Then: Immediate reconnection attempt should occur
|
||||||
|
expect(connectionAttempts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases and Error Handling', () => {
|
||||||
|
it('should handle missing connection state gracefully', async () => {
|
||||||
|
// Given: Connection service returns undefined
|
||||||
|
vi.mocked(connectionService.getConnectionState).mockReturnValue(undefined);
|
||||||
|
|
||||||
|
// When: Connection status change occurs
|
||||||
|
await handler.onMothershipConnectionStatusChanged();
|
||||||
|
|
||||||
|
// Then: No errors should occur, no reconnection attempts
|
||||||
|
expect(connectionAttempts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed connection state', async () => {
|
||||||
|
// Given: Malformed connection state
|
||||||
|
vi.mocked(connectionService.getConnectionState).mockReturnValue({
|
||||||
|
status: 'UNKNOWN_STATUS' as any,
|
||||||
|
error: 'Malformed state',
|
||||||
|
});
|
||||||
|
|
||||||
|
// When: Connection status change occurs
|
||||||
|
await handler.onMothershipConnectionStatusChanged();
|
||||||
|
|
||||||
|
// Then: Should not trigger reconnection for unknown states
|
||||||
|
expect(connectionAttempts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,7 @@ export class MothershipHandler {
|
|||||||
const state = this.connectionService.getConnectionState();
|
const state = this.connectionService.getConnectionState();
|
||||||
if (
|
if (
|
||||||
state &&
|
state &&
|
||||||
[MinigraphStatus.PING_FAILURE, MinigraphStatus.ERROR_RETRYING].includes(state.status)
|
[MinigraphStatus.PING_FAILURE].includes(state.status)
|
||||||
) {
|
) {
|
||||||
this.logger.verbose(
|
this.logger.verbose(
|
||||||
'Mothership connection status changed to %s; setting up mothership subscription',
|
'Mothership connection status changed to %s; setting up mothership subscription',
|
||||||
|
|||||||
Reference in New Issue
Block a user