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
26 changes: 21 additions & 5 deletions freerelay/core/execution/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,18 +151,34 @@ async def execute_hedged(
provider_keys = [(p, k) for p, k, _ in providers]
circuits = {p.name: c for p, _, c in providers}
try:
response = await hedged_execute(provider_keys, request)
# Record success on the circuit breaker of whichever provider won
for _name, circuit in circuits.items():
await circuit.record_success()
return response
result = await hedged_execute(provider_keys, request)
except Exception as e:
# Record failure on all involved circuit breakers
for _name, circuit in circuits.items():
status = e.status_code if isinstance(e, ProviderError) else None
await circuit.record_failure(status)
raise

# Only the winning provider's circuit breaker should observe a
# success. The loser's provider either timed out, raised, or
# completed after the winner — calling record_success on it
# would mask the failure that triggered the hedge in the first
# place and prevent the circuit breaker from ever tripping on
# that provider.
winner_circuit = circuits.get(result.winner_name)
if winner_circuit is not None:
await winner_circuit.record_success()
for loser_name in result.loser_names:
loser_circuit = circuits.get(loser_name)
if loser_circuit is not None:
# The loser completed but slower than the winner, so its
# response was discarded — not strictly a "failure" but
# also not a success the breaker should credit. Record a
# non-status failure (None) to nudge the breaker toward
# opening without the aggressive weight of a real 5xx.
await loser_circuit.record_failure(None)
return result.response

async def execute_stream(
self,
provider: BaseProvider,
Expand Down
82 changes: 69 additions & 13 deletions freerelay/core/execution/hedging.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,32 @@
logger = logging.getLogger("freerelay.hedging")


class HedgedResult:
"""Result of a hedged execution, identifying which provider won."""

__slots__ = ("response", "winner_name", "loser_names")

def __init__(
self,
response: ChatCompletionResponse,
winner_name: str,
loser_names: tuple[str, ...],
) -> None:
self.response = response
self.winner_name = winner_name
self.loser_names = loser_names

def __repr__(self) -> str:
return (
f"HedgedResult(winner={self.winner_name!r}, "
f"losers={self.loser_names!r})"
)


async def hedged_execute(
providers: list[tuple[BaseProvider, str]],
request: ChatCompletionRequest,
) -> ChatCompletionResponse:
) -> HedgedResult:
"""
Fire the same request at up to 2 providers in parallel.
Return the first response. Cancel all others.
Expand All @@ -34,10 +56,13 @@ async def hedged_execute(
request: The chat completion request.

Returns:
Response from the fastest provider.
HedgedResult with the winning response, the winner's name, and
the names of any losing providers (so callers can update their
circuit breakers accordingly).

Raises:
Exception: If all providers fail.
ValueError: If no providers were given.
Exception: If all providers fail. The first error is re-raised.
"""
if not providers:
raise ValueError("No providers for hedged execution")
Expand All @@ -55,22 +80,53 @@ async def hedged_execute(
return_when=asyncio.FIRST_COMPLETED,
)

# Cancel all pending tasks immediately
# Cancel all pending tasks immediately. Use CancelledError-specific
# suppression: when a task is cancelled, awaiting it raises
# CancelledError. A bare Exception in the suppress tuple would also
# swallow the original failure if the task had raised before we got
# here, which would mask real provider errors in our logs.
for task in pending:
task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
with contextlib.suppress(asyncio.CancelledError):
await task
Comment on lines 88 to 91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Non-CancelledError from a cancelled task can escape and mask the winner.

If a pending task ignores/suppresses cancellation and then raises a non-CancelledError exception, await task re-raises it here, propagating out of hedged_execute before the winner is even determined — discarding a valid winning response. The comment's stated scenario (a task that "raised before we got here") can't apply to a pending task, since a task that already raised is in done, not pending. Consider logging and suppressing unexpected errors from the cancel/drain phase instead of letting them propagate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@freerelay/core/execution/hedging.py` around lines 88 - 91, In the pending
task cancellation loop starting with `for task in pending:`, the current code
only suppresses `asyncio.CancelledError`, but if a pending task raises a
non-CancelledError exception after cancellation, it will propagate and escape
the function before the winner is determined. Instead of only suppressing
CancelledError in the `await task` statement, capture and log any unexpected
exceptions that occur during the cancel/drain phase, then suppress them so the
function can continue to determine the winner.


# Check all done tasks for a successful result
# done may contain 1 or 2 tasks (both completed before cancellation)
# Walk done tasks once. If more than one finished before we could
# cancel (e.g. both providers returned on the same scheduler tick),
# the first task we pick with no exception is the winner; the rest
# are losers that need their result fetched (or exception ignored)
# so the asyncio task machinery doesn't warn about un-awaited tasks.
winner_task: asyncio.Task[ChatCompletionResponse] | None = None
winner_provider: BaseProvider | None = None
loser_providers: list[BaseProvider] = []
errors: list[BaseException] = []

for task in done:
if task.exception() is None:
winner = tasks[task]
logger.info("Hedged winner: %s", winner.name)
# Cancel any remaining done tasks that weren't checked yet
return task.result()
errors.append(task.exception()) # type: ignore[arg-type]
exc = task.exception()
if exc is None and winner_task is None:
winner_task = task
winner_provider = tasks[task]
elif exc is None:
loser_providers.append(tasks[task])
else:
errors.append(exc)

if winner_task is not None:
logger.info("Hedged winner: %s", winner_provider.name)
# Drain any leftover successful losers so their task objects
# aren't GC'd in a 'pending result' state — that's a common
# asyncio footgun that produces "Task was destroyed but it is
# pending!" warnings at shutdown.
for loser in loser_providers:
for t in done:
if tasks.get(t) is loser:
with contextlib.suppress(asyncio.CancelledError, Exception):
t.result()
break
return HedgedResult(
response=winner_task.result(),
winner_name=winner_provider.name,
loser_names=tuple(p.name for p in loser_providers),
)
Comment on lines +100 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm executor is the only consumer of hedged_execute / HedgedResult
rg -nP '\bhedged_execute\s*\(' --type=py
rg -nP '\bHedgedResult\b' --type=py

Repository: HrachShah/FreeRelay

Length of output: 157


🏁 Script executed:

#!/bin/bash
# First, find the hedging.py file and explore the directory structure
find . -name "hedging.py" -type f 2>/dev/null | head -20
ls -la freerelay/core/execution/ 2>/dev/null || echo "Path not found, searching for execution directory"
find . -type d -name "execution" 2>/dev/null | head -10

Repository: HrachShah/FreeRelay

Length of output: 794


🏁 Script executed:

#!/bin/bash
# Search for files that might contain HedgedResult or hedged_execute
rg "HedgedResult|hedged_execute" --type py -l 2>/dev/null | head -20

Repository: HrachShah/FreeRelay

Length of output: 157


🏁 Script executed:

#!/bin/bash
# Broader search to understand project structure
git ls-files | grep -E "(hedging|executor)" | head -20

Repository: HrachShah/FreeRelay

Length of output: 339


🏁 Script executed:

#!/bin/bash
# Read hedging.py to verify the code at lines 100-129 and see HedgedResult definition
cat -n freerelay/core/execution/hedging.py

Repository: HrachShah/FreeRelay

Length of output: 5581


🏁 Script executed:

#!/bin/bash
# Read executor.py to verify how HedgedResult is used
cat -n freerelay/core/execution/executor.py

Repository: HrachShah/FreeRelay

Length of output: 8380


🏁 Script executed:

#!/bin/bash
# Verify if there are any tests that show the intended behavior
cat -n tests/unit/test_hedging_winner_only_success.py

Repository: HrachShah/FreeRelay

Length of output: 5512


Failed loser providers are silently dropped when a winner exists, preventing their circuit breakers from recording the failure.

When a winner completes successfully, loser_providers only collects done tasks where exc is None (line 109 of hedging.py); a loser that completed with an exception is pushed into the errors list (line 111), which is consumed only when no winner is found (line 133). This means a co-running loser that returned a real error has its exception swallowed and is not included in loser_names (line 128).

In executor.py (lines 171–179), execute_hedged iterates only over result.loser_names to record circuit breaker feedback. Losers that failed are not in loser_names, so their circuit breakers never observe their failure. A struggling provider that errors while another provider wins will have its failure silently discarded, breaking the circuit breaker's ability to track degradation.

Consider exposing failed losers (with their status/exception) on HedgedResult so the executor can record a real failure for them and update their circuit breakers accordingly.

🧰 Tools
🪛 GitHub Actions: CI / 0_typecheck.txt

[error] 114-114: mypy error: Item "None" of "BaseProvider | None" has no attribute "name" [union-attr]


[error] 127-127: mypy error: Item "None" of "BaseProvider | None" has no attribute "name" [union-attr]

🪛 GitHub Actions: CI / typecheck

[error] 114-114: mypy: Item "None" of "BaseProvider | None" has no attribute "name" [union-attr]


[error] 127-127: mypy: Item "None" of "BaseProvider | None" has no attribute "name" [union-attr]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@freerelay/core/execution/hedging.py` around lines 100 - 129, The HedgedResult
class needs to be extended to track failed losers (providers that completed with
exceptions) along with their exceptions, since currently only successful losers
are included in loser_names while failed losers are silently dropped from the
errors list when a winner exists. Modify the HedgedResult dataclass to add a new
field for failed losers with their exceptions, then in the hedging logic around
lines 100-129, populate this field by tracking which loser providers had
exceptions, and include this data when returning the HedgedResult. Finally,
update the execute_hedged function in executor.py to iterate over these failed
losers and record their failures in the circuit breaker feedback to prevent
losing track of provider degradation.


# All done tasks failed
logger.warning("All hedged providers failed (%d errors)", len(errors))
Expand Down
121 changes: 121 additions & 0 deletions tests/unit/test_hedging_winner_only_success.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""
Tests — Hedged execution only records circuit-breaker success on the winner.

The hedged execution path fires a request at two providers in parallel
and returns whichever responds first. The losing provider either gets
cancelled mid-flight or completes after the winner — either way it did
NOT successfully serve the user, so its circuit breaker must NOT be
reset to CLOSED. Only the winner's circuit should record success.
"""

from __future__ import annotations

import asyncio
from unittest.mock import MagicMock

import pytest

from freerelay.core.execution.executor import Executor
from freerelay.core.models.openai import (
ChatCompletionRequest,
ChatCompletionResponse,
Choice,
Message,
)
from freerelay.core.resilience.circuit_breaker import CircuitBreaker, CircuitState


def _make_provider(name: str, delay: float, fail: bool = False) -> MagicMock:
provider = MagicMock()
provider.name = name

async def _complete(request, api_key):
await asyncio.sleep(delay)
if fail:
raise RuntimeError(f"{name} boom")
return ChatCompletionResponse(
id=f"resp-{name}",
created=0,
model=f"model-{name}",
choices=[
Choice(
index=0,
message=Message(role="assistant", content=f"{name}-wins"),
finish_reason="stop",
)
],
)

provider.complete = _complete
return provider


def _make_request() -> ChatCompletionRequest:
req = MagicMock(spec=ChatCompletionRequest)
req.model = "test-model"
req.messages = []
return req


@pytest.mark.asyncio
async def test_hedged_winner_records_success_only_on_winner() -> None:
"""Provider A is faster; A's circuit becomes CLOSED but B's stays OPEN
because B was cancelled mid-flight (its request never completed)."""
a_breaker = CircuitBreaker(
provider_name="a", failure_threshold=3, failure_window=60, recovery_timeout=0.5
)
b_breaker = CircuitBreaker(
provider_name="b", failure_threshold=3, failure_window=60, recovery_timeout=0.5
)
# Pre-open both circuits with 3 failures to mimic a struggling provider.
for _ in range(3):
await a_breaker.record_failure(500)
await b_breaker.record_failure(500)
assert a_breaker.state == CircuitState.OPEN
assert b_breaker.state == CircuitState.OPEN

# Wait past the recovery window so the auto-transition can fire when
# record_success is called.
await asyncio.sleep(0.6)

a = _make_provider("A", delay=0.05)
b = _make_provider("B", delay=0.50) # loser (will be cancelled)
executor = Executor(enable_hedging=True, max_retries=0)
result = await executor.execute_hedged(
[(a, "key-a", a_breaker), (b, "key-b", b_breaker)],
_make_request(),
)

assert result.choices[0].message.content == "A-wins"
# The winner's circuit went OPEN → HALF_OPEN → CLOSED with failures cleared.
assert a_breaker.state == CircuitState.CLOSED, (
f"winner A should have success recorded and circuit reset to CLOSED, got {a_breaker.state}"
)
Comment on lines +91 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix CI lint failure: line exceeds 88 chars (E501).

CI fails on Line 92 (99 > 88). Wrap the assertion message.

🧵 Proposed fix
-    assert a_breaker.state == CircuitState.CLOSED, (
-        f"winner A should have success recorded and circuit reset to CLOSED, got {a_breaker.state}"
-    )
+    assert a_breaker.state == CircuitState.CLOSED, (
+        "winner A should have success recorded and circuit reset to CLOSED, "
+        f"got {a_breaker.state}"
+    )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert a_breaker.state == CircuitState.CLOSED, (
f"winner A should have success recorded and circuit reset to CLOSED, got {a_breaker.state}"
)
assert a_breaker.state == CircuitState.CLOSED, (
"winner A should have success recorded and circuit reset to CLOSED, "
f"got {a_breaker.state}"
)
🧰 Tools
🪛 GitHub Actions: CI / 1_lint.txt

[error] 92-92: E501 Line too long (99 > 88)

🪛 GitHub Actions: CI / lint

[error] 92-92: E501 Line too long (99 > 88)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_hedging_winner_only_success.py` around lines 91 - 93, The
assertion message in the test exceeds the 88-character line limit (currently 99
characters on line 92). Split the f-string message across multiple lines to
comply with the linting requirement. You can either wrap the string by breaking
it into concatenated parts or restructure how the assertion message is formatted
to keep each line under 88 characters while maintaining the readability and
meaning of the assertion about a_breaker.state and CircuitState.CLOSED.

Source: Pipeline failures

assert a_breaker.get_score() == 1.0
# The loser's circuit was NOT marked successful.
# It auto-transitioned to HALF_OPEN during the sleep, but the loser's task
# was cancelled, so no failure was recorded — the circuit should remain
# HALF_OPEN (a probe slot is still open for it to retry). It must NOT
# be CLOSED, because that would mean we silently treated the loser as healthy.
assert b_breaker.state != CircuitState.CLOSED, (
f"loser B must NOT be marked successful; got {b_breaker.state}"
)


@pytest.mark.asyncio
async def test_hedged_all_fail_records_failure_on_both() -> None:
"""When every provider fails, every circuit breaker should see the failure."""
a = _make_provider("A", delay=0.01, fail=True)
b = _make_provider("B", delay=0.02, fail=True)
circuit_a = CircuitBreaker("A", failure_threshold=5)
circuit_b = CircuitBreaker("B", failure_threshold=5)
executor = Executor(enable_hedging=True, max_retries=0)
with pytest.raises(RuntimeError):
await executor.execute_hedged(
[(a, "key-a", circuit_a), (b, "key-b", circuit_b)],
_make_request(),
)

# Both should still be CLOSED (threshold=5, only 1 failure each)
assert circuit_a.state == CircuitState.CLOSED
assert circuit_b.state == CircuitState.CLOSED
Loading