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:
kollaikal-rupesh
2026-03-10 12:37:30 -07:00
committed by GitHub
parent 43a9e9a1b5
commit 80bd935c19
6 changed files with 264 additions and 86 deletions

View File

@@ -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()