Add ServiceSwitcherStrategyFailover for automatic failover on service errors (#3870)
* Add ServiceSwitcherStrategyFailover for automatic error-based service switching Introduce a strategy hierarchy: ServiceSwitcherStrategy (base) → ServiceSwitcherStrategyManual (handles ManuallySwitchServiceFrame) → ServiceSwitcherStrategyFailover (adds error-based failover). ServiceSwitcher now defaults to ServiceSwitcherStrategyManual with strategy_type optional. Non-fatal ErrorFrames are forwarded to the strategy via handle_error(). * Move metadata request into _set_active_if_available Requesting metadata is part of making a service active, so it belongs alongside setting _active_service and firing on_service_switched. This removes the duplicate queue_frame calls from ServiceSwitcher push_frame and process_frame.
This commit is contained in:
@@ -11,6 +11,7 @@ import unittest
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pipecat.frames.frames import (
|
||||
ErrorFrame,
|
||||
Frame,
|
||||
ManuallySwitchServiceFrame,
|
||||
ServiceMetadataFrame,
|
||||
@@ -20,7 +21,12 @@ from pipecat.frames.frames import (
|
||||
TextFrame,
|
||||
)
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.service_switcher import ServiceSwitcher, ServiceSwitcherStrategyManual
|
||||
from pipecat.pipeline.service_switcher import (
|
||||
ServiceSwitcher,
|
||||
ServiceSwitcherStrategy,
|
||||
ServiceSwitcherStrategyFailover,
|
||||
ServiceSwitcherStrategyManual,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
from pipecat.tests.utils import run_test
|
||||
|
||||
@@ -106,8 +112,8 @@ class DummySystemFrame(SystemFrame):
|
||||
text: str = ""
|
||||
|
||||
|
||||
class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase):
|
||||
"""Test cases for ServiceSwitcherStrategyManual."""
|
||||
class TestServiceSwitcherStrategy(unittest.IsolatedAsyncioTestCase):
|
||||
"""Test cases for the base ServiceSwitcherStrategy."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
@@ -118,10 +124,54 @@ class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
def test_init_with_services(self):
|
||||
"""Test initialization with a list of services."""
|
||||
strategy = ServiceSwitcherStrategyManual(self.services)
|
||||
strategy = ServiceSwitcherStrategy(self.services)
|
||||
|
||||
self.assertEqual(strategy.services, self.services)
|
||||
self.assertEqual(strategy.active_service, self.service1) # First service should be active
|
||||
self.assertEqual(strategy.active_service, self.service1)
|
||||
|
||||
async def test_handle_frame_returns_none_for_manual_switch(self):
|
||||
"""Test that base strategy does not handle ManuallySwitchServiceFrame."""
|
||||
strategy = ServiceSwitcherStrategy(self.services)
|
||||
|
||||
switch_frame = ManuallySwitchServiceFrame(service=self.service2)
|
||||
result = await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
||||
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual(strategy.active_service, self.service1)
|
||||
|
||||
async def test_handle_frame_returns_none_for_unsupported_frame(self):
|
||||
"""Test that unsupported frame types return None."""
|
||||
strategy = ServiceSwitcherStrategy(self.services)
|
||||
unsupported_frame = TextFrame(text="test")
|
||||
|
||||
result = await strategy.handle_frame(unsupported_frame, FrameDirection.DOWNSTREAM)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
async def test_handle_error_returns_none(self):
|
||||
"""Test that handle_error returns None by default."""
|
||||
strategy = ServiceSwitcherStrategy(self.services)
|
||||
|
||||
result = await strategy.handle_error(ErrorFrame(error="error"))
|
||||
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual(strategy.active_service, self.service1)
|
||||
|
||||
|
||||
class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase):
|
||||
"""Test cases for ServiceSwitcherStrategyManual."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.service1 = MockFrameProcessor("service1")
|
||||
self.service2 = MockFrameProcessor("service2")
|
||||
self.service3 = MockFrameProcessor("service3")
|
||||
self.services = [self.service1, self.service2, self.service3]
|
||||
|
||||
def test_is_subclass_of_base_strategy(self):
|
||||
"""Test that ServiceSwitcherStrategyManual is a subclass of ServiceSwitcherStrategy."""
|
||||
strategy = ServiceSwitcherStrategyManual(self.services)
|
||||
self.assertIsInstance(strategy, ServiceSwitcherStrategy)
|
||||
|
||||
async def test_handle_manually_switch_service_frame(self):
|
||||
"""Test manual service switching with ManuallySwitchServiceFrame."""
|
||||
@@ -129,22 +179,15 @@ class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
# Initially service1 should be active
|
||||
self.assertEqual(strategy.active_service, self.service1)
|
||||
self.assertNotEqual(strategy.active_service, self.service2)
|
||||
|
||||
# Switch to service2
|
||||
switch_frame = ManuallySwitchServiceFrame(service=self.service2)
|
||||
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
||||
|
||||
self.assertNotEqual(strategy.active_service, self.service1)
|
||||
self.assertEqual(strategy.active_service, self.service2)
|
||||
self.assertNotEqual(strategy.active_service, self.service3)
|
||||
|
||||
# Switch to service3
|
||||
switch_frame = ManuallySwitchServiceFrame(service=self.service3)
|
||||
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
||||
|
||||
self.assertNotEqual(strategy.active_service, self.service1)
|
||||
self.assertNotEqual(strategy.active_service, self.service2)
|
||||
self.assertEqual(strategy.active_service, self.service3)
|
||||
|
||||
async def test_on_service_switched_event(self):
|
||||
@@ -157,25 +200,16 @@ class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase):
|
||||
async def on_service_switched(strategy, service):
|
||||
switched_events.append((strategy, service))
|
||||
|
||||
# Switch to service2
|
||||
switch_frame = ManuallySwitchServiceFrame(service=self.service2)
|
||||
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
||||
await asyncio.sleep(0) # Let async event task run
|
||||
await asyncio.sleep(0)
|
||||
|
||||
self.assertEqual(len(switched_events), 1)
|
||||
self.assertIsInstance(switched_events[0][0], ServiceSwitcherStrategyManual)
|
||||
self.assertEqual(switched_events[0][1], self.service2)
|
||||
|
||||
# Switch to service3
|
||||
switch_frame = ManuallySwitchServiceFrame(service=self.service3)
|
||||
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
self.assertEqual(len(switched_events), 2)
|
||||
self.assertEqual(switched_events[1][1], self.service3)
|
||||
|
||||
async def test_on_service_switched_event_not_fired_for_unknown_service(self):
|
||||
"""Test that on_service_switched event does not fire for services not in the list."""
|
||||
async def test_unknown_service_ignored(self):
|
||||
"""Test that switching to an unknown service is ignored."""
|
||||
strategy = ServiceSwitcherStrategyManual(self.services)
|
||||
|
||||
switched_events = []
|
||||
@@ -184,23 +218,14 @@ class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase):
|
||||
async def on_service_switched(strategy, service):
|
||||
switched_events.append(service)
|
||||
|
||||
# Try switching to a service not in the list
|
||||
unknown_service = MockFrameProcessor("unknown")
|
||||
switch_frame = ManuallySwitchServiceFrame(service=unknown_service)
|
||||
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
||||
result = await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
self.assertEqual(len(switched_events), 0)
|
||||
self.assertEqual(strategy.active_service, self.service1) # Unchanged
|
||||
|
||||
async def test_handle_frame_unsupported_frame_type(self):
|
||||
"""Test that unsupported frame types raise an error."""
|
||||
strategy = ServiceSwitcherStrategyManual(self.services)
|
||||
unsupported_frame = TextFrame(text="test") # Not a ServiceSwitcherFrame
|
||||
|
||||
result = await strategy.handle_frame(unsupported_frame, FrameDirection.DOWNSTREAM)
|
||||
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual(len(switched_events), 0)
|
||||
self.assertEqual(strategy.active_service, self.service1)
|
||||
|
||||
|
||||
class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
|
||||
@@ -213,9 +238,9 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
|
||||
self.service3 = MockFrameProcessor("service3")
|
||||
self.services = [self.service1, self.service2, self.service3]
|
||||
|
||||
def test_init_with_manual_strategy(self):
|
||||
"""Test initialization with manual strategy."""
|
||||
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
|
||||
def test_init_with_default_strategy(self):
|
||||
"""Test initialization with default strategy."""
|
||||
switcher = ServiceSwitcher(self.services)
|
||||
|
||||
self.assertEqual(switcher.services, self.services)
|
||||
self.assertIsInstance(switcher.strategy, ServiceSwitcherStrategyManual)
|
||||
@@ -223,7 +248,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_default_active_service(self):
|
||||
"""Test that the initially-active service receives frames while others don't."""
|
||||
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
|
||||
switcher = ServiceSwitcher(self.services)
|
||||
|
||||
# Reset counters
|
||||
for service in self.services:
|
||||
@@ -292,7 +317,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_service_switching(self):
|
||||
"""Test that after service switching using ManuallySwitchServiceFrame, the new active service receives frames while others don't."""
|
||||
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
|
||||
switcher = ServiceSwitcher(self.services)
|
||||
|
||||
# Reset counters
|
||||
for service in self.services:
|
||||
@@ -341,8 +366,8 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
|
||||
switcher2_services = [switcher2_service1, switcher2_service2]
|
||||
|
||||
# Create two service switchers
|
||||
switcher1 = ServiceSwitcher(switcher1_services, ServiceSwitcherStrategyManual)
|
||||
switcher2 = ServiceSwitcher(switcher2_services, ServiceSwitcherStrategyManual)
|
||||
switcher1 = ServiceSwitcher(switcher1_services)
|
||||
switcher2 = ServiceSwitcher(switcher2_services)
|
||||
|
||||
# Create a pipeline with both switchers: switcher1 -> switcher2
|
||||
pipeline = Pipeline([switcher1, switcher2])
|
||||
@@ -428,7 +453,7 @@ class TestServiceSwitcherMetadata(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_only_active_service_metadata_at_startup(self):
|
||||
"""Test that only the active service's metadata leaves the ServiceSwitcher at startup."""
|
||||
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
|
||||
switcher = ServiceSwitcher(self.services)
|
||||
|
||||
# Run the pipeline (StartFrame triggers metadata emission)
|
||||
output_frames = []
|
||||
@@ -450,7 +475,7 @@ class TestServiceSwitcherMetadata(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_metadata_emitted_on_service_switch(self):
|
||||
"""Test that switching services triggers metadata emission from the new active service."""
|
||||
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
|
||||
switcher = ServiceSwitcher(self.services)
|
||||
|
||||
# Reset counters after startup
|
||||
self.service1.reset_counters()
|
||||
@@ -482,7 +507,7 @@ class TestServiceSwitcherMetadata(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_inactive_service_metadata_blocked(self):
|
||||
"""Test that metadata from inactive services is blocked."""
|
||||
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
|
||||
switcher = ServiceSwitcher(self.services)
|
||||
|
||||
# Run and collect output frames
|
||||
await run_test(
|
||||
@@ -497,5 +522,80 @@ class TestServiceSwitcherMetadata(unittest.IsolatedAsyncioTestCase):
|
||||
# Only one MockMetadataFrame should have left (from service1)
|
||||
|
||||
|
||||
class TestServiceSwitcherStrategyFailover(unittest.IsolatedAsyncioTestCase):
|
||||
"""Test cases for ServiceSwitcherStrategyFailover."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.service1 = MockFrameProcessor("service1")
|
||||
self.service2 = MockFrameProcessor("service2")
|
||||
self.service3 = MockFrameProcessor("service3")
|
||||
self.services = [self.service1, self.service2, self.service3]
|
||||
|
||||
def test_init_defaults(self):
|
||||
"""Test that default values are set correctly."""
|
||||
strategy = ServiceSwitcherStrategyFailover(self.services)
|
||||
self.assertEqual(strategy.active_service, self.service1)
|
||||
|
||||
async def test_error_switches_to_next_service(self):
|
||||
"""Test that an error on the active service switches to the next one."""
|
||||
strategy = ServiceSwitcherStrategyFailover(self.services)
|
||||
|
||||
error = ErrorFrame(error="connection lost")
|
||||
result = await strategy.handle_error(error)
|
||||
|
||||
self.assertEqual(result, self.service2)
|
||||
self.assertEqual(strategy.active_service, self.service2)
|
||||
|
||||
async def test_consecutive_errors_cycle_through_services(self):
|
||||
"""Test that repeated errors cycle through all services."""
|
||||
strategy = ServiceSwitcherStrategyFailover(self.services)
|
||||
|
||||
# First error: service1 -> service2
|
||||
await strategy.handle_error(ErrorFrame(error="error 1"))
|
||||
self.assertEqual(strategy.active_service, self.service2)
|
||||
|
||||
# Second error: service2 -> service3
|
||||
await strategy.handle_error(ErrorFrame(error="error 2"))
|
||||
self.assertEqual(strategy.active_service, self.service3)
|
||||
|
||||
# Third error: service3 -> service1 (wraps around)
|
||||
await strategy.handle_error(ErrorFrame(error="error 3"))
|
||||
self.assertEqual(strategy.active_service, self.service1)
|
||||
|
||||
async def test_single_service_returns_none(self):
|
||||
"""Test that handle_error returns None with only one service."""
|
||||
strategy = ServiceSwitcherStrategyFailover([self.service1])
|
||||
|
||||
result = await strategy.handle_error(ErrorFrame(error="error"))
|
||||
self.assertIsNone(result)
|
||||
|
||||
async def test_manual_switch_still_works(self):
|
||||
"""Test that ManuallySwitchServiceFrame is still handled."""
|
||||
strategy = ServiceSwitcherStrategyFailover(self.services)
|
||||
|
||||
frame = ManuallySwitchServiceFrame(service=self.service3)
|
||||
result = await strategy.handle_frame(frame, FrameDirection.DOWNSTREAM)
|
||||
|
||||
self.assertEqual(result, self.service3)
|
||||
self.assertEqual(strategy.active_service, self.service3)
|
||||
|
||||
async def test_on_service_switched_event_fires_on_error(self):
|
||||
"""Test that on_service_switched event fires when an error triggers a switch."""
|
||||
strategy = ServiceSwitcherStrategyFailover(self.services)
|
||||
|
||||
switched_events = []
|
||||
|
||||
@strategy.event_handler("on_service_switched")
|
||||
async def on_service_switched(strategy, service):
|
||||
switched_events.append(service)
|
||||
|
||||
await strategy.handle_error(ErrorFrame(error="error"))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
self.assertEqual(len(switched_events), 1)
|
||||
self.assertEqual(switched_events[0], self.service2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user