Skip to content

Commit 917880a

Browse files
Circuit breaker flag for consecutively failing automations
1 parent f1b3244 commit 917880a

5 files changed

Lines changed: 504 additions & 58 deletions

File tree

openhands/automation/config.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,12 @@ class ServiceSettings(BaseSettings):
451451
AUTOMATION_WATCHDOG_INTERVAL_SECONDS: Watchdog poll interval (default: 60)
452452
AUTOMATION_FAILURE_DISABLE_THRESHOLD: Consecutive permanent failures before
453453
auto-disabling an automation (default: 3, <=0 disables auto-disable)
454+
AUTOMATION_CONSECUTIVE_FAILURE_DISABLE_THRESHOLD: Consecutive failures
455+
before auto-disabling. Unset (the default) turns the rule off;
456+
setting a number turns it on and uses that number.
457+
AUTOMATION_CONSECUTIVE_FAILURE_DISABLE_WINDOW_HOURS: The rule only trips
458+
if nothing has succeeded in this many hours, which is what makes it
459+
ignore provider outages shorter than the window (default: 24)
454460
455461
# API pagination
456462
AUTOMATION_API_DEFAULT_PAGE_SIZE: Default page size (default: 50)
@@ -534,8 +540,20 @@ class ServiceSettings(BaseSettings):
534540
dispatcher_interval_seconds: int = 10
535541
dispatcher_batch_size: int = 10
536542
watchdog_interval_seconds: int = 60
543+
544+
# Circuit breaker. `failure_disable_threshold` is the fast path for
545+
# unambiguous config faults (bad key, revoked token) and needs no time
546+
# guard. The two rules below catch automations that merely fail forever;
547+
# their span/window guards are what keep a provider outage from pausing
548+
# healthy automations en masse, so both must exceed any tolerable outage.
537549
failure_disable_threshold: int = 3
538550

551+
# Setting a threshold at all is what turns the consecutive rule on; it is
552+
# off by default. The window only applies once the rule is on, and must
553+
# exceed any outage you would rather ride out than pause for.
554+
consecutive_failure_disable_threshold: int | None = None
555+
consecutive_failure_disable_window_hours: float = 24.0
556+
539557
# How long an accepted event stays in `integration_events`. It bounds two
540558
# things: the dedupe window (a redelivery older than this is indistinguishable
541559
# from a new event) and the table, which otherwise grows with every delivery.

openhands/automation/dispatcher.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,9 @@ async def _fail(
236236
else None,
237237
run_id=run.id,
238238
)
239-
elif status_detail is not None:
239+
else:
240+
# Evaluate whether the automation has failed consecutively and may never
241+
# succeed
240242
automation_disabled = await maybe_disable_unhealthy_automation_after_run(
241243
session_factory,
242244
automation.id,

openhands/automation/utils/unhealthy.py

Lines changed: 205 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,30 @@
1-
"""Classify unhealthy automations and auto-disable chronic permanent failures."""
1+
"""Circuit breaker that pauses automations which keep failing.
2+
3+
Two rules, both evaluated from a single fetch of the automation's most recent
4+
terminal runs:
5+
6+
1. Consecutive *permanent* failures (bad key, revoked token). Unambiguous, so
7+
it trips fast and needs no time guard.
8+
2. The last N runs all failed and nothing has succeeded in the configured
9+
window.
10+
11+
Rule 2's window is what keeps a provider outage from pausing healthy
12+
automations: a run count alone cannot tell "broken forever" from "the provider
13+
was down", because a five-minute cron racks up hundreds of failures during any
14+
outage. Asking whether anything succeeded recently answers that directly, and
15+
is indifferent to how often the automation runs.
16+
"""
217

318
from __future__ import annotations
419

520
import logging
621
import uuid
7-
from collections.abc import Mapping
22+
from collections.abc import Mapping, Sequence
23+
from dataclasses import dataclass
24+
from datetime import datetime, timedelta
825
from typing import Any
926

10-
from sqlalchemy import CursorResult, select, update
27+
from sqlalchemy import CursorResult, func, select, update
1128
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
1229

1330
from openhands.automation.config import get_config
@@ -19,7 +36,7 @@
1936
AutomationRunStatus,
2037
)
2138
from openhands.automation.utils.run import skip_pending_runs_for_disabled_automation
22-
from openhands.automation.utils.time import utcnow
39+
from openhands.automation.utils.time import ensure_utc, utcnow
2340

2441

2542
logger = logging.getLogger(__name__)
@@ -30,6 +47,9 @@
3047
AutomationRunStatus.FAILED,
3148
)
3249

50+
SOURCE_PERMANENT = "consecutive_permanent_failures"
51+
SOURCE_CONSECUTIVE = "consecutive_failures"
52+
3353

3454
def _string_value(value: Any) -> str | None:
3555
if isinstance(value, str) and value:
@@ -66,71 +86,153 @@ def is_permanent_failure_detail(detail: Mapping[str, Any] | None) -> bool:
6686
return detail.get("user_action") == "settings"
6787

6888

69-
def _disabled_reason(detail: Mapping[str, Any], count: int) -> str:
70-
formatted = _string_value(detail.get("formatted_detail"))
71-
message = formatted or _string_value(detail.get("detail")) or "Permanent failure"
72-
kind = _string_value(detail.get("kind")) or "permanent_failure"
73-
return f"{kind}: {message} (seen in {count} consecutive runs)"
89+
def _humanize(hours: float) -> str:
90+
if hours < 48:
91+
return f"{round(hours)} hours"
92+
return f"{round(hours / 24)} days"
93+
94+
95+
@dataclass(frozen=True)
96+
class _Trip:
97+
"""A breaker rule that fired, and everything needed to record it."""
7498

99+
reason: str
100+
source: str
101+
detail: dict[str, Any]
102+
run_id: uuid.UUID | None
75103

76-
async def get_consecutive_permanent_failure_count(
104+
105+
async def _history_boundary(
106+
session: AsyncSession, automation_id: uuid.UUID
107+
) -> datetime | None:
108+
"""Timestamp before which run history is ignored.
109+
110+
Re-enabling an automation leaves its old failures in scope, so without
111+
this the very next failure trips the breaker again immediately. A
112+
re-enable always follows a disable event, so the latest event is the
113+
boundary.
114+
"""
115+
return await session.scalar(
116+
select(func.max(AutomationDisableEvent.created_at)).where(
117+
AutomationDisableEvent.automation_id == automation_id
118+
)
119+
)
120+
121+
122+
async def _recent_terminal_runs(
77123
session: AsyncSession,
78124
automation_id: uuid.UUID,
79-
*,
125+
boundary: datetime | None,
80126
limit: int,
81-
) -> tuple[int, dict[str, Any] | None, uuid.UUID | None]:
82-
"""Count latest consecutive terminal runs with permanent failure details."""
127+
) -> Sequence[AutomationRun]:
128+
"""The automation's most recent terminal runs, newest first."""
129+
stmt = select(AutomationRun).where(
130+
AutomationRun.automation_id == automation_id,
131+
AutomationRun.status.in_(TERMINAL_OUTCOME_STATUSES),
132+
)
133+
if boundary is not None:
134+
stmt = stmt.where(AutomationRun.created_at > boundary)
83135
result = await session.execute(
84-
select(AutomationRun)
136+
stmt.order_by(AutomationRun.created_at.desc()).limit(limit)
137+
)
138+
return result.scalars().all()
139+
140+
141+
async def _succeeded_since(
142+
session: AsyncSession,
143+
automation_id: uuid.UUID,
144+
since: datetime,
145+
) -> bool:
146+
"""Whether any run has completed since `since`. Stops at the first hit."""
147+
found = await session.scalar(
148+
select(AutomationRun.id)
85149
.where(
86150
AutomationRun.automation_id == automation_id,
87-
AutomationRun.status.in_(TERMINAL_OUTCOME_STATUSES),
151+
AutomationRun.status == AutomationRunStatus.COMPLETED,
152+
AutomationRun.created_at > since,
88153
)
89-
.order_by(AutomationRun.created_at.desc())
90-
.limit(limit)
154+
.limit(1)
91155
)
156+
return found is not None
92157

158+
159+
def _leading_failures(runs: Sequence[AutomationRun]) -> int:
93160
count = 0
94-
latest_detail: dict[str, Any] | None = None
95-
latest_run_id: uuid.UUID | None = None
96-
for run in result.scalars().all():
97-
detail = run.status_detail
98-
if not is_permanent_failure_detail(detail):
161+
for run in runs:
162+
if run.status != AutomationRunStatus.FAILED:
99163
break
100164
count += 1
101-
if latest_detail is None:
102-
latest_detail = detail
103-
latest_run_id = run.id
104-
return count, latest_detail, latest_run_id
165+
return count
105166

106167

107-
async def maybe_disable_unhealthy_automation(
108-
session: AsyncSession,
109-
automation_id: uuid.UUID,
110-
*,
111-
threshold: int | None = None,
112-
) -> bool:
113-
"""Disable an automation once permanent failures reach the threshold."""
114-
if threshold is None:
115-
threshold = get_config().service.failure_disable_threshold
116-
if threshold <= 0:
117-
return False
168+
def _check_permanent(
169+
runs: Sequence[AutomationRun],
170+
threshold: int,
171+
) -> _Trip | None:
172+
"""Trip on consecutive terminal runs that all carry permanent-fault detail."""
173+
if threshold <= 0 or len(runs) < threshold:
174+
return None
118175

119-
count, latest_detail, latest_run_id = await get_consecutive_permanent_failure_count(
120-
session,
121-
automation_id,
122-
limit=threshold,
176+
count = 0
177+
for run in runs:
178+
if not is_permanent_failure_detail(run.status_detail):
179+
break
180+
count += 1
181+
if count < threshold:
182+
return None
183+
184+
detail = runs[0].status_detail or {}
185+
formatted = _string_value(detail.get("formatted_detail"))
186+
message = formatted or _string_value(detail.get("detail")) or "unknown error"
187+
kind = _string_value(detail.get("kind")) or "permanent_failure"
188+
return _Trip(
189+
reason=(
190+
f"Paused automatically: {kind}{message}. "
191+
f"This failed the last {count} runs and needs a configuration fix."
192+
),
193+
source=SOURCE_PERMANENT,
194+
detail={
195+
"rule": SOURCE_PERMANENT,
196+
"threshold": threshold,
197+
"consecutive_permanent_failures": count,
198+
"status_detail": detail,
199+
},
200+
run_id=runs[0].id,
123201
)
124-
if count < threshold or latest_detail is None:
125-
return False
126202

127-
disabled_reason = _disabled_reason(latest_detail, count)
203+
204+
def _consecutive_trip(
205+
runs: Sequence[AutomationRun],
206+
threshold: int,
207+
window_hours: float,
208+
) -> _Trip:
209+
count = _leading_failures(runs)
210+
return _Trip(
211+
reason=(
212+
f"Paused automatically: the last {count} runs all failed and nothing "
213+
f"has succeeded in {_humanize(window_hours)}."
214+
),
215+
source=SOURCE_CONSECUTIVE,
216+
detail={
217+
"rule": SOURCE_CONSECUTIVE,
218+
"threshold": threshold,
219+
"consecutive_failures": count,
220+
"window_hours": window_hours,
221+
},
222+
run_id=runs[0].id,
223+
)
224+
225+
226+
async def _apply_disable(
227+
session: AsyncSession,
228+
automation_id: uuid.UUID,
229+
trip: _Trip,
230+
) -> bool:
128231
disabled_detail = {
129-
"reason": disabled_reason,
130-
"threshold": threshold,
131-
"consecutive_permanent_failures": count,
132-
"run_id": str(latest_run_id) if latest_run_id else None,
133-
"status_detail": latest_detail,
232+
"reason": trip.reason,
233+
"source": trip.source,
234+
"run_id": str(trip.run_id) if trip.run_id else None,
235+
**trip.detail,
134236
}
135237
disabled_at = utcnow()
136238
result: CursorResult = await session.execute( # type: ignore[assignment]
@@ -141,7 +243,7 @@ async def maybe_disable_unhealthy_automation(
141243
)
142244
.values(
143245
enabled=False,
144-
disabled_reason=disabled_reason,
246+
disabled_reason=trip.reason,
145247
disabled_detail=disabled_detail,
146248
disabled_at=disabled_at,
147249
)
@@ -152,7 +254,7 @@ async def maybe_disable_unhealthy_automation(
152254
await skip_pending_runs_for_disabled_automation(
153255
session,
154256
automation_id,
155-
reason=disabled_reason,
257+
reason=trip.reason,
156258
disabled_detail=disabled_detail,
157259
completed_at=disabled_at,
158260
)
@@ -164,21 +266,67 @@ async def maybe_disable_unhealthy_automation(
164266
session.add(
165267
AutomationDisableEvent(
166268
automation_id=automation_id,
167-
run_id=latest_run_id,
168-
reason=disabled_reason,
269+
run_id=trip.run_id,
270+
reason=trip.reason,
169271
detail=disabled_detail,
170-
source="consecutive_permanent_failures",
272+
source=trip.source,
171273
)
172274
)
173275

174276
logger.warning(
175-
"Automation disabled after %s consecutive permanent failures",
176-
count,
177-
extra={"automation_id": str(automation_id), "run_id": str(latest_run_id)},
277+
"Automation auto-disabled (%s): %s",
278+
trip.source,
279+
trip.reason,
280+
extra={
281+
"automation_id": str(automation_id),
282+
"run_id": str(trip.run_id) if trip.run_id else None,
283+
},
178284
)
179285
return True
180286

181287

288+
async def maybe_disable_unhealthy_automation(
289+
session: AsyncSession,
290+
automation_id: uuid.UUID,
291+
*,
292+
threshold: int | None = None,
293+
) -> bool:
294+
"""Disable an automation if either circuit breaker rule has tripped.
295+
296+
Args:
297+
threshold: Override for the permanent-failure rule only; the
298+
consecutive rule always reads its config values.
299+
"""
300+
service = get_config().service
301+
if threshold is None:
302+
threshold = service.failure_disable_threshold
303+
threshold = max(threshold, 0)
304+
consecutive = max(service.consecutive_failure_disable_threshold or 0, 0)
305+
window_hours = service.consecutive_failure_disable_window_hours
306+
307+
limit = max(threshold, consecutive)
308+
if limit <= 0:
309+
return False
310+
311+
boundary = await _history_boundary(session, automation_id)
312+
runs = await _recent_terminal_runs(session, automation_id, boundary, limit)
313+
314+
trip = _check_permanent(runs, threshold)
315+
316+
# The all-failed check is in memory, so the success lookup only runs for
317+
# automations that are already failing straight through.
318+
if trip is None and consecutive > 0 and _leading_failures(runs) >= consecutive:
319+
since = utcnow() - timedelta(hours=window_hours)
320+
if boundary is not None:
321+
since = max(since, ensure_utc(boundary))
322+
if not await _succeeded_since(session, automation_id, since):
323+
trip = _consecutive_trip(runs, consecutive, window_hours)
324+
325+
if trip is None:
326+
return False
327+
return await _apply_disable(session, automation_id, trip)
328+
329+
182330
async def maybe_disable_unhealthy_automation_after_run(
183331
session_factory: async_sessionmaker[AsyncSession],
184332
automation_id: uuid.UUID,

0 commit comments

Comments
 (0)