Skip to content

Commit 3d1633c

Browse files
fix(control): close the captcha governance loop (#65)
PR #63 added error_type='captcha_challenge' classification but nothing consumed it: is_retryable() treated it as permanent (unknown types default to permanent), so a Doubao captcha wall permanently failed the task with no recovery path, while the control layer's pause/review machinery (actuator, paused_until, review_required) had no scheduler consumer at all. - error_taxonomy: add CAPTCHA_CHALLENGE + is_captcha() — a third category (needs human), neither retryable nor permanent - error_kinds: map captcha_challenge -> ErrorKind.CAPTCHA for the control vocabulary - actuator: add pause_source_for_captcha() — pause + require_review in one call, keeping the actuator the only mutator of DataSource - pipeline: on captcha_challenge collect failure, pause the source (TTL from control_pause_ttl_seconds) + flag review instead of failing permanently; best-effort so actuator/DB errors never mask the original collect error - scheduler: _get_enabled_schedules now also skips sources with review_required=True, so a paused/flagged source is never dispatched until a human clears it (control loop writes the state, scheduler honors it) Tests: taxonomy (is_captcha/retryability), error_kinds mapping, actuator pause+review (incl. idempotent re-pause), pipeline wiring (captcha pauses, ordinary failures don't), scheduler review/disabled gating. Co-authored-by: 1012839419a-alt <1012839419a-alt@users.noreply.github.com>
1 parent d6b6c89 commit 3d1633c

10 files changed

Lines changed: 328 additions & 3 deletions

File tree

backend/control/actuator.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,30 @@ async def execute_action(
205205
)
206206

207207

208+
async def pause_source_for_captcha(
209+
session: AsyncSession, *, source: DataSource, now: datetime, ttl_seconds: int
210+
) -> dict[str, Any]:
211+
"""Pause a source that hit a human-cleared challenge wall (captcha) and
212+
flag it for review.
213+
214+
The pipeline calls this when a channel classifies a collect failure as
215+
``captcha_challenge`` (see ``backend.pipeline.error_taxonomy.is_captcha``):
216+
automatic retry would burn budget on the same wall, so instead the source
217+
is disabled for ``ttl_seconds`` (the normal pause TTL semantics — the
218+
scheduler already skips disabled sources, and
219+
:func:`auto_resume_expired_pauses` re-enables it when the wall should have
220+
cooled down) and ``review_required`` is set so the UI surfaces it for a
221+
human to confirm/clear.
222+
223+
Stays inside the actuator: this module remains the ONLY code allowed to
224+
mutate a ``DataSource`` on the control system's behalf.
225+
"""
226+
detail = await _apply_pause(session, source=source, now=now, ttl_seconds=ttl_seconds)
227+
review_detail = await _apply_require_review(session, source=source)
228+
detail["review_required"] = review_detail["already_flagged"] or True
229+
return detail
230+
231+
208232
async def auto_resume_expired_pauses(
209233
session: AsyncSession, *, now: datetime
210234
) -> list[tuple[DataSource, ExecutionResult]]:

backend/control/error_kinds.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class ErrorKind(str, Enum):
3434
ODP_UNAVAILABLE = "odp_unavailable"
3535
STORE_FAILED = "store_failed"
3636
POISON_MESSAGE = "poison_message"
37+
# Human-cleared challenge wall (Doubao captcha) — the controller should
38+
# pause + require review, not backoff/retry on its own.
39+
CAPTCHA = "captcha"
3740
UNKNOWN = "unknown"
3841

3942

@@ -95,6 +98,9 @@ class ErrorKind(str, Enum):
9598
# Poison message (DLQ-bound: a message that will never succeed no matter
9699
# how many times it's retried)
97100
"PoisonMessageError": ErrorKind.POISON_MESSAGE,
101+
# Human-cleared challenge wall (doubao_research_channel's captcha
102+
# classification — see error_taxonomy.CAPTCHA_CHALLENGE)
103+
"captcha_challenge": ErrorKind.CAPTCHA,
98104
}
99105

100106

backend/pipeline/error_taxonomy.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@
4242
})
4343

4444

45+
#: Explicit error_type for a site wall that only a human can clear (Doubao's
46+
#: captcha/人机验证, set by doubao_research_channel). Deliberately NOT in either
47+
#: set below: it is not a transient fault (retrying burns budget on the same
48+
#: wall) and not a permanent fault (the source is fine once a human clears it)
49+
#: — the pipeline treats it via :func:`is_captcha` (pause + require review).
50+
CAPTCHA_CHALLENGE = "captcha_challenge"
51+
52+
53+
def is_captcha(error_type: str | None) -> bool:
54+
"""True when the failure is a human-cleared challenge wall (captcha).
55+
56+
Distinct from retryability: a captcha is neither transient nor permanent —
57+
the correct response is to pause the source and surface it for human
58+
action, not to retry automatically.
59+
"""
60+
return error_type == CAPTCHA_CHALLENGE
61+
62+
4563
def is_retryable(error_type: str | None) -> bool:
4664
"""Classify a failure by its exception class name.
4765

backend/pipeline/pipeline.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from backend.control.recorder import FreshnessInfo, record_run_measurement
1414
from backend.models.source import DataSource
1515
from backend.pipeline import events
16-
from backend.pipeline.error_taxonomy import effective_error_type, is_retryable
16+
from backend.pipeline.error_taxonomy import effective_error_type, is_captcha, is_retryable
1717

1818
logger = logging.getLogger(__name__)
1919

@@ -255,6 +255,48 @@ async def run_pipeline(
255255
)
256256
if is_retryable(channel_result.error_type):
257257
raise ChannelFetchError(channel_result.error or "collect failed")
258+
if is_captcha(channel_result.error_type):
259+
# Human-cleared challenge wall (Doubao captcha). Automatic retry
260+
# would burn budget on the same wall and a permanent failure hides
261+
# the recovery path, so instead pause the source (scheduler
262+
# already skips disabled sources) and flag it for review — a human
263+
# clears the wall, TTL expiry auto-resumes. Best-effort: a DB or
264+
# actuator failure here must not mask the original collect error.
265+
try:
266+
from backend.config import get_settings
267+
from backend.control.actuator import pause_source_for_captcha
268+
from backend.database import AsyncSessionLocal
269+
270+
ttl = get_settings().control_pause_ttl_seconds
271+
async with AsyncSessionLocal() as session:
272+
src = await session.get(DataSource, source.id)
273+
if src is not None:
274+
await pause_source_for_captcha(
275+
session,
276+
source=src,
277+
now=datetime.now(timezone.utc),
278+
ttl_seconds=ttl,
279+
)
280+
await session.commit()
281+
logger.warning(
282+
"[task:%s] captcha wall | paused source=%s (ttl=%ss, review_required)",
283+
task_id, source.id, ttl,
284+
)
285+
if run_id:
286+
await events.emit(
287+
run_id, "collect",
288+
"验证码拦截:数据源已暂停,等待人工处理",
289+
level="warning",
290+
detail={"captcha_paused": True, "pause_ttl_seconds": ttl},
291+
)
292+
except Exception:
293+
logger.exception("[task:%s] failed to pause source on captcha", task_id)
294+
return PipelineResult(
295+
success=False,
296+
source_id=source.id,
297+
error=channel_result.error,
298+
metadata={"captcha_paused": True},
299+
)
258300
if run_id:
259301
await _record_measurement_best_effort(
260302
source_id=source.id, run_id=run_id,

backend/scheduler.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ async def _get_enabled_schedules() -> list[dict]:
2929
select(CronSchedule, DataSource)
3030
.join(DataSource, CronSchedule.source_id == DataSource.id)
3131
.where(CronSchedule.enabled.is_(True), DataSource.enabled.is_(True))
32+
# A source flagged review_required (e.g. by a captcha pause — see
33+
# backend.pipeline.pipeline's captcha branch) must not be
34+
# dispatched until a human clears the flag; the control loop
35+
# writes the state, the scheduler honors it.
36+
.where(DataSource.review_required.is_(False))
3237
)
3338
return [
3439
{

tests/unit/control/test_actuator.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,38 @@ async def test_dangerous_suggestion_downgrades_and_preserves_original(db_session
264264
# Source was NOT paused or otherwise mutated beyond the review flag —
265265
# the downgrade never performs the originally-suggested action.
266266
assert source.enabled is True
267+
268+
269+
# ── pause_source_for_captcha (captcha governance, PR-captcha-governance) ──
270+
271+
272+
@pytest.mark.asyncio
273+
async def test_pause_source_for_captcha_pauses_and_flags_review(db_session):
274+
source = await _make_source(db_session)
275+
276+
detail = await actuator.pause_source_for_captcha(
277+
db_session, source=source, now=NOW, ttl_seconds=900
278+
)
279+
280+
assert source.enabled is False
281+
assert source.paused_until == NOW + timedelta(seconds=900)
282+
assert source.review_required is True
283+
assert detail["paused_until"] == source.paused_until.isoformat()
284+
assert detail["review_required"] is True
285+
assert detail["was_enabled"] is True
286+
287+
288+
@pytest.mark.asyncio
289+
async def test_pause_source_for_captcha_refreshes_ttl_when_already_paused(db_session):
290+
source = await _make_source(db_session, enabled=False)
291+
source.paused_until = NOW - timedelta(seconds=1)
292+
source.review_required = True
293+
await db_session.flush()
294+
295+
detail = await actuator.pause_source_for_captcha(
296+
db_session, source=source, now=NOW, ttl_seconds=1800
297+
)
298+
299+
assert source.paused_until == NOW + timedelta(seconds=1800)
300+
assert source.review_required is True
301+
assert detail["was_enabled"] is False

tests/unit/control/test_error_kinds.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ def test_schema_drift(self):
4545
def test_store_failed(self):
4646
assert map_error_type("IntegrityError") is ErrorKind.STORE_FAILED
4747

48+
def test_captcha_challenge_maps_to_captcha(self):
49+
assert map_error_type("captcha_challenge") is ErrorKind.CAPTCHA
50+
4851

4952
class TestMapException:
5053
def test_none_maps_to_unknown(self):

tests/unit/pipeline/test_error_taxonomy.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
import pytest
44

5-
from backend.pipeline.error_taxonomy import is_retryable, is_retryable_http_status
5+
from backend.pipeline.error_taxonomy import (
6+
CAPTCHA_CHALLENGE,
7+
is_captcha,
8+
is_retryable,
9+
is_retryable_http_status,
10+
)
611

712

813
@pytest.mark.parametrize("error_type", [
@@ -60,3 +65,33 @@ def test_request_timeout_408_is_retryable():
6065
"""408 is a transient per-request timeout, not a durably broken request —
6166
it belongs with 429/5xx, not with the permanent 4xx family."""
6267
assert is_retryable_http_status(408) is True
68+
69+
70+
# ── captcha_challenge: third category (needs human, not retry/permanent) ─────
71+
72+
73+
def test_captcha_challenge_constant_value():
74+
assert CAPTCHA_CHALLENGE == "captcha_challenge"
75+
76+
77+
def test_captcha_challenge_is_not_retryable():
78+
"""A captcha wall is not a transient fault — retrying immediately burns
79+
retry budget on a wall that only a human can clear."""
80+
assert is_retryable(CAPTCHA_CHALLENGE) is False
81+
82+
83+
def test_captcha_challenge_is_captcha():
84+
assert is_captcha(CAPTCHA_CHALLENGE) is True
85+
86+
87+
def test_none_is_not_captcha():
88+
assert is_captcha(None) is False
89+
90+
91+
def test_empty_string_is_not_captcha():
92+
assert is_captcha("") is False
93+
94+
95+
def test_ordinary_errors_are_not_captcha():
96+
for t in ("TimeoutException", "ValueError", "RetryableHTTPStatus", "SomeNewError"):
97+
assert is_captcha(t) is False

tests/unit/pipeline/test_pipeline_errors.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,89 @@ async def test_pipeline_collect_exception(db_session):
6161
assert "network down" in result.error
6262

6363

64+
# ── captcha governance wiring (PR-captcha-governance) ────────────────────
65+
66+
67+
@pytest.mark.asyncio
68+
async def test_pipeline_captcha_failure_pauses_source_for_review(db_session):
69+
"""A collect failure classified as captcha_challenge pauses the source
70+
(enabled=False + review_required=True) instead of failing permanently or
71+
retrying automatically."""
72+
from backend.models.source import DataSource
73+
from backend.models.task import CollectionTask
74+
75+
source = DataSource(
76+
name="Captcha Source",
77+
channel_type="doubao_research",
78+
channel_config={"question": "x"},
79+
)
80+
db_session.add(source)
81+
await db_session.flush()
82+
83+
task = CollectionTask(source_id=source.id, trigger_type="manual", parameters={})
84+
db_session.add(task)
85+
await db_session.flush()
86+
87+
channel_result = ChannelResult.fail("verification challenge", error_type="captcha_challenge")
88+
89+
mock_session = AsyncMock()
90+
mock_session.get = AsyncMock(return_value=source)
91+
mock_session.commit = AsyncMock()
92+
mock_session_cm = AsyncMock()
93+
mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session)
94+
mock_session_cm.__aexit__ = AsyncMock(return_value=False)
95+
96+
with (
97+
patch("backend.pipeline.collector.collect", return_value=channel_result),
98+
patch("backend.database.AsyncSessionLocal", return_value=mock_session_cm),
99+
patch("backend.control.actuator.pause_source_for_captcha", new_callable=AsyncMock) as mock_pause,
100+
patch("backend.config.get_settings") as mock_settings,
101+
):
102+
mock_settings.return_value.control_pause_ttl_seconds = 900
103+
result = await run_pipeline(task.id, source)
104+
105+
assert result.success is False
106+
assert "verification challenge" in result.error
107+
assert result.metadata.get("captcha_paused") is True
108+
mock_pause.assert_awaited_once()
109+
assert mock_pause.await_args.kwargs["source"].id == source.id
110+
assert mock_pause.await_args.kwargs["ttl_seconds"] == 900
111+
assert source.enabled is True # the real pause happens in the actuator via the mocked call
112+
113+
114+
@pytest.mark.asyncio
115+
async def test_pipeline_ordinary_failure_does_not_pause_source(db_session):
116+
"""Non-captcha failures keep the existing permanent-failure path and never
117+
touch the actuator."""
118+
from backend.models.source import DataSource
119+
from backend.models.task import CollectionTask
120+
121+
source = DataSource(
122+
name="Plain Fail Source",
123+
channel_type="rss",
124+
channel_config={"feed_url": "https://ex.com/feed.xml"},
125+
)
126+
db_session.add(source)
127+
await db_session.flush()
128+
129+
task = CollectionTask(source_id=source.id, trigger_type="manual", parameters={})
130+
db_session.add(task)
131+
await db_session.flush()
132+
133+
channel_result = ChannelResult.fail("feed malformed", error_type="JSONDecodeError")
134+
135+
with (
136+
patch("backend.pipeline.collector.collect", return_value=channel_result),
137+
patch("backend.control.actuator.pause_source_for_captcha", new_callable=AsyncMock) as mock_pause,
138+
):
139+
result = await run_pipeline(db_session, source, task.id)
140+
141+
assert result.success is False
142+
assert "feed malformed" in result.error
143+
assert "captcha_paused" not in (result.metadata or {})
144+
mock_pause.assert_not_awaited()
145+
146+
64147
@pytest.mark.asyncio
65148
async def test_pipeline_with_ai_failure_still_returns_success(db_session):
66149
from backend.models.source import DataSource

0 commit comments

Comments
 (0)