Skip to content

Commit 9a2fb0c

Browse files
committed
Add rollout result post processor
1 parent cb3206c commit 9a2fb0c

File tree

5 files changed

+180
-258
lines changed

5 files changed

+180
-258
lines changed

eval_protocol/pytest/evaluation_test_utils.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -374,9 +374,6 @@ async def execute_row_with_backoff_retry(row: EvaluationRow) -> EvaluationRow:
374374
except ResponseQualityError as quality_error:
375375
# Re-raise ResponseQualityError to trigger retry logic
376376
raise quality_error
377-
except Exception as post_process_error:
378-
# Wrap unexpected post-processor errors in ResponseQualityError
379-
raise ResponseQualityError(f"Post-processor failed: {post_process_error}") from post_process_error
380377

381378
return result
382379

@@ -393,8 +390,6 @@ async def execute_row_with_backoff(task: asyncio.Task[EvaluationRow], row: Evalu
393390
config.post_processor.process(result)
394391
except ResponseQualityError as quality_error:
395392
raise quality_error
396-
except Exception as post_process_error:
397-
raise ResponseQualityError(f"Post-processor failed: {post_process_error}") from post_process_error
398393

399394
_set_rollout_status_to_finished(result)
400395

eval_protocol/pytest/exception_config.py

Lines changed: 3 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,11 @@ class BackoffConfig:
7979
# Optional custom giveup function - if provided, overrides the default exception handling logic
8080
giveup_func: Callable[[Exception], bool] = lambda e: False
8181

82-
def get_backoff_decorator(self, exceptions: Set[Type[Exception]], exception_backoff_overrides: Dict[Type[Exception], "BackoffConfig"] | None = None):
82+
def get_backoff_decorator(self, exceptions: Set[Type[Exception]]):
8383
"""Get the appropriate backoff decorator based on configuration.
8484
8585
Args:
8686
exceptions: Set of exception types to retry
87-
exception_backoff_overrides: Optional mapping of exception types to custom backoff configs.
88-
If an exception type has an override, that config will be used instead of this one.
8987
"""
9088
if not exceptions:
9189
# If no exceptions specified, return a no-op decorator
@@ -94,67 +92,7 @@ def no_op_decorator(func):
9492

9593
return no_op_decorator
9694

97-
# If no overrides, use simple decorator for all exceptions
98-
if not exception_backoff_overrides:
99-
return self._create_single_decorator(exceptions, self)
100-
101-
# Group exceptions by their backoff config to avoid double backoff
102-
# Each exception type gets exactly one decorator based on its config
103-
# Use a tuple of config attributes as the key since BackoffConfig is not hashable
104-
config_to_exceptions: Dict[tuple, tuple[Set[Type[Exception]], "BackoffConfig"]] = {}
105-
106-
for exc_type in exceptions:
107-
if exc_type in exception_backoff_overrides:
108-
override_config = exception_backoff_overrides[exc_type]
109-
else:
110-
override_config = self
111-
112-
# Create a hashable key from config attributes
113-
# Note: jitter and giveup_func are callable, which are hashable in Python
114-
config_key = (
115-
override_config.strategy,
116-
override_config.base_delay,
117-
override_config.max_delay,
118-
override_config.max_tries,
119-
override_config.factor,
120-
id(override_config.jitter) if override_config.jitter is not None else None,
121-
id(override_config.giveup_func) if override_config.giveup_func is not None else None,
122-
override_config.raise_on_giveup,
123-
)
124-
125-
if config_key not in config_to_exceptions:
126-
config_to_exceptions[config_key] = (set(), override_config)
127-
exc_set, _ = config_to_exceptions[config_key]
128-
exc_set.add(exc_type)
129-
130-
# If all exceptions use the same config, use a single decorator
131-
if len(config_to_exceptions) == 1:
132-
exc_set, config = next(iter(config_to_exceptions.values()))
133-
return self._create_single_decorator(exc_set, config)
134-
135-
# Create separate decorators for each config group
136-
# Each exception type gets exactly one decorator, preventing double backoff
137-
decorators_by_config: list[tuple[Set[Type[Exception]], Callable]] = []
138-
139-
for exc_set, config in config_to_exceptions.values():
140-
decorator = self._create_single_decorator(exc_set, config)
141-
if decorator:
142-
decorators_by_config.append((exc_set, decorator))
143-
144-
# Create a combined decorator that applies all decorators
145-
# Each decorator only catches exceptions in its exception set, so no double backoff
146-
def combined_decorator(func):
147-
decorated_func = func
148-
149-
# Apply each decorator in order (inner to outer)
150-
# Each decorator only catches exceptions in its specific exception set
151-
# Since exception sets are disjoint (grouped by config), no double backoff
152-
for exc_set, decorator in decorators_by_config:
153-
decorated_func = decorator(decorated_func)
154-
155-
return decorated_func
156-
157-
return combined_decorator
95+
return self._create_single_decorator(exceptions, self)
15896

15997
def _create_single_decorator(self, exc_set: Set[Type[Exception]], config: "BackoffConfig"):
16098
"""Create a single backoff decorator for a set of exceptions."""
@@ -197,10 +135,6 @@ class ExceptionHandlerConfig:
197135
# Backoff configuration
198136
backoff_config: BackoffConfig = field(default_factory=BackoffConfig)
199137

200-
# Per-exception backoff overrides - allows custom backoff config for specific exception types
201-
# For example, ResponseQualityError can use no backoff (base_delay=0, max_delay=0)
202-
exception_backoff_overrides: Dict[Type[Exception], BackoffConfig] = field(default_factory=dict)
203-
204138
def __post_init__(self):
205139
"""Automatically apply environment variable overrides after initialization."""
206140
# Override backoff settings from environment variables
@@ -211,22 +145,11 @@ def __post_init__(self):
211145
if "EP_FAIL_ON_MAX_RETRY" in os.environ:
212146
fail_on_max_retry = os.environ["EP_FAIL_ON_MAX_RETRY"].lower()
213147
self.backoff_config.raise_on_giveup = fail_on_max_retry != "false"
214-
215-
# Set default no-backoff config for ResponseQualityError if not already set
216-
if eval_protocol.exceptions.ResponseQualityError not in self.exception_backoff_overrides:
217-
# Default: no backoff for ResponseQualityError (immediate retry)
218-
self.exception_backoff_overrides[eval_protocol.exceptions.ResponseQualityError] = BackoffConfig(
219-
strategy="constant",
220-
base_delay=0.0,
221-
max_delay=0.0,
222-
max_tries=self.backoff_config.max_tries,
223-
)
224148

225149
def get_backoff_decorator(self):
226150
"""Get the backoff decorator configured for this exception handler."""
227151
return self.backoff_config.get_backoff_decorator(
228-
self.retryable_exceptions,
229-
self.exception_backoff_overrides if self.exception_backoff_overrides else None
152+
self.retryable_exceptions
230153
)
231154

232155

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
Rollout result post-processing plugin for quality checks.
3+
4+
This module provides an abstract base class for post-processing rollout results
5+
to guard response quality. Post-processors can validate results and raise
6+
ResponseQualityError if quality checks fail.
7+
"""
8+
9+
from abc import ABC, abstractmethod
10+
11+
from eval_protocol.models import EvaluationRow
12+
13+
14+
class RolloutResultPostProcessor(ABC):
15+
"""
16+
Abstract base class for rollout result post-processing plugins.
17+
18+
Post-processors validate rollout results and can raise ResponseQualityError
19+
if quality checks fail. This allows for customizable quality guards that
20+
can be overridden by users.
21+
"""
22+
23+
@abstractmethod
24+
def process(self, result: EvaluationRow) -> None:
25+
"""
26+
Process and validate a rollout result.
27+
28+
This method should perform quality checks on the result. If quality
29+
checks fail, it should raise ResponseQualityError with an appropriate
30+
message.
31+
32+
Args:
33+
result: The EvaluationRow result from the rollout
34+
35+
Raises:
36+
ResponseQualityError: If quality checks fail
37+
"""
38+
pass
39+
40+
41+
class NoOpRolloutResultPostProcessor(RolloutResultPostProcessor):
42+
"""
43+
Default no-op implementation of RolloutResultPostProcessor.
44+
45+
This implementation does not perform any quality checks and always passes.
46+
Use this as a default when no post-processing is needed.
47+
"""
48+
49+
def process(self, result: EvaluationRow) -> None:
50+
"""
51+
No-op implementation that does not perform any quality checks.
52+
53+
Args:
54+
result: The EvaluationRow result from the rollout
55+
"""
56+
pass
57+

tests/test_exception_config.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""
2+
Unit tests for exception_config module.
3+
4+
Tests the BackoffConfig and ExceptionHandlerConfig classes, including:
5+
1. Backoff decorator creation
6+
2. Per-exception backoff overrides
7+
3. ResponseQualityError default no-backoff configuration
8+
4. Exception grouping to avoid double backoff
9+
"""
10+
11+
import pytest
12+
from eval_protocol.pytest.exception_config import BackoffConfig, ExceptionHandlerConfig, DEFAULT_RETRYABLE_EXCEPTIONS
13+
from eval_protocol.exceptions import ResponseQualityError
14+
15+
16+
def test_backoff_config_no_exceptions():
17+
"""Test that BackoffConfig returns no-op decorator when no exceptions specified."""
18+
config = BackoffConfig()
19+
decorator = config.get_backoff_decorator(set())
20+
21+
# Should be a no-op decorator
22+
def test_func():
23+
return "test"
24+
25+
decorated = decorator(test_func)
26+
assert decorated() == "test"
27+
assert decorated is test_func # Should be the same function
28+
29+
30+
def test_backoff_config_no_overrides():
31+
"""Test that BackoffConfig creates a single decorator."""
32+
config = BackoffConfig(strategy="constant", base_delay=0.1, max_tries=2)
33+
exceptions = {ConnectionError, TimeoutError}
34+
35+
decorator = config.get_backoff_decorator(exceptions)
36+
assert decorator is not None
37+
38+
# Decorator should be callable
39+
def test_func():
40+
raise ConnectionError("test")
41+
42+
decorated = decorator(test_func)
43+
assert callable(decorated)
44+
45+
46+
def test_exception_handler_config_default_response_quality_error():
47+
"""Test that ExceptionHandlerConfig includes ResponseQualityError by default."""
48+
config = ExceptionHandlerConfig()
49+
50+
# ResponseQualityError should be in retryable_exceptions
51+
assert ResponseQualityError in config.retryable_exceptions
52+
53+
54+
def test_exception_handler_config_get_backoff_decorator():
55+
"""Test that ExceptionHandlerConfig.get_backoff_decorator() works correctly."""
56+
config = ExceptionHandlerConfig()
57+
decorator = config.get_backoff_decorator()
58+
59+
assert decorator is not None
60+
assert callable(decorator)
61+
62+
# Should be able to decorate a function
63+
def test_func():
64+
raise ConnectionError("test")
65+
66+
decorated = decorator(test_func)
67+
assert callable(decorated)
68+
69+
70+
def test_backoff_config_expo_strategy():
71+
72+
"""Test that BackoffConfig creates expo decorator correctly."""
73+
config = BackoffConfig(strategy="expo", base_delay=1.0, max_tries=2)
74+
exceptions = {ConnectionError}
75+
76+
decorator = config.get_backoff_decorator(exceptions)
77+
assert decorator is not None
78+
79+
def test_func():
80+
raise ConnectionError("test")
81+
82+
decorated = decorator(test_func)
83+
assert callable(decorated)
84+
85+
86+
def test_backoff_config_constant_strategy():
87+
"""Test that BackoffConfig creates constant decorator correctly."""
88+
config = BackoffConfig(strategy="constant", base_delay=0.1, max_tries=2)
89+
exceptions = {ConnectionError}
90+
91+
decorator = config.get_backoff_decorator(exceptions)
92+
assert decorator is not None
93+
94+
def test_func():
95+
raise ConnectionError("test")
96+
97+
decorated = decorator(test_func)
98+
assert callable(decorated)
99+
100+
101+
def test_backoff_config_invalid_strategy():
102+
"""Test that BackoffConfig raises ValueError for invalid strategy."""
103+
config = BackoffConfig(strategy="invalid", base_delay=1.0, max_tries=2)
104+
exceptions = {ConnectionError}
105+
106+
with pytest.raises(ValueError, match="Unknown backoff strategy"):
107+
config.get_backoff_decorator(exceptions)
108+
109+
110+
def test_exception_handler_config_response_quality_error_in_defaults():
111+
"""Test that ResponseQualityError is in DEFAULT_RETRYABLE_EXCEPTIONS."""
112+
assert ResponseQualityError in DEFAULT_RETRYABLE_EXCEPTIONS
113+
114+

0 commit comments

Comments
 (0)