Skip to content

Commit d66ce32

Browse files
feat(channels): migrate doubao/douyin to thick fetch() with bounded retry (#66)
Both session-affinity channels declared capabilities (default_rate, auth_kind) but never overrode fetch(), so run_channel treated them as unmigrated: no RateLimitedClient, no runner protocol, and CDP race failures (CDP connection is not open, Inspected target navigated or closed, cloneNode) had no retry semantics — the outer worker had to brute-force retry, and a captcha wall was the only classified error. - doubao_research_channel: classify transient CDP races as ConnectionError (retryable in error_taxonomy) alongside the existing captcha_challenge classification; add fetch() override with exponential-backoff bounded retry (max_retries default 3, retry_base_delay default 2s). Captcha is NEVER auto-retried — the pipeline's captcha branch (PR #65) pauses the source for a human. - douyin_detail_channel: same transient classification on open/eval failures + same fetch() retry loop; permanent failures fail immediately. - collect() stays the single source of truth in both; ctx.http is not threaded through (subprocess transport, same trade-off as opencli_channel.fetch()). Tests: 19 doubao (7 new), 6 douyin (3 new) — retry-then-success, give-up after max_retries, captcha/permanent never retried, metadata passthrough, CDP transient classification. channel contract + runner suites green. Co-authored-by: 1012839419a-alt <1012839419a-alt@users.noreply.github.com>
1 parent ba99d25 commit d66ce32

4 files changed

Lines changed: 349 additions & 7 deletions

File tree

‎backend/channels/doubao_research_channel.py‎

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
import re
55
from typing import Any
66

7-
from backend.channels.base import AbstractChannel, Capabilities, ChannelResult
7+
from backend.channels.base import (
8+
AbstractChannel,
9+
Capabilities,
10+
ChannelFetchError,
11+
ChannelResult,
12+
FetchContext,
13+
FetchResult,
14+
)
815
from backend.channels.registry import register_channel
916

1017
_URL_RE = re.compile(r"https?://[^\s<>\[\](){}'\"]+", re.IGNORECASE)
@@ -17,6 +24,18 @@
1724
"人机验证",
1825
"验证码",
1926
)
27+
#: Transient CDP/browser race conditions — retrying the same question in the
28+
#: same session usually succeeds once the navigation settles. Classified as
29+
#: ``ConnectionError`` (retryable in error_taxonomy) so the thick ``fetch()``
30+
#: retry loop picks them up, while a captcha wall (above) is NOT retried.
31+
_TRANSIENT_CDP_MARKERS = (
32+
"CDP connection is not open",
33+
"Inspected target navigated or closed",
34+
"Execution context was destroyed",
35+
"Target closed",
36+
"Session closed",
37+
"cloneNode",
38+
)
2039

2140

2241
def _citations(text: str) -> list[dict[str, str]]:
@@ -66,6 +85,13 @@ def _is_captcha_block(stderr: str, stdout: str) -> bool:
6685
return any(marker in text for marker in _CAPTCHA_MARKERS)
6786

6887

88+
def _is_transient_cdp_fault(stderr: str, stdout: str) -> bool:
89+
"""True when the adapter hit a CDP/browser race (navigation, closed tab,
90+
session teardown) — retrying the same question usually succeeds."""
91+
text = f"{stderr} {stdout}"
92+
return any(marker in text for marker in _TRANSIENT_CDP_MARKERS)
93+
94+
6995
async def _run_doubao_command(command: list[str]) -> tuple[int, str, str]:
7096
"""Late import avoids the channel registry's legacy OpenCLI import cycle."""
7197
from backend.channels.opencli_channel import _run_opencli
@@ -131,9 +157,16 @@ async def collect(self, config: dict[str, Any], parameters: dict[str, Any]) -> C
131157
)
132158

133159
if returncode:
134-
# Classify captcha walls so the runner can apply a human-in-the-loop
135-
# or cooldown-retry policy instead of treating it as a permanent failure.
136-
error_type = "captcha_challenge" if _is_captcha_block(stderr, stdout) else None
160+
# Classify: captcha walls (human-cleared — never auto-retry) and
161+
# transient CDP races (retryable) get structured error_types so
162+
# the runner / thick fetch() can act on them; anything else stays
163+
# a generic failure instead of a permanent misclassification.
164+
if _is_captcha_block(stderr, stdout):
165+
error_type = "captcha_challenge"
166+
elif _is_transient_cdp_fault(stderr, stdout):
167+
error_type = "ConnectionError"
168+
else:
169+
error_type = None
137170
return ChannelResult.fail(
138171
f"opencli doubao ask exited with code {returncode}: {stderr[:500]}",
139172
error_type=error_type,
@@ -195,3 +228,54 @@ async def validate_config(self, config: dict[str, Any]) -> list[str]:
195228
if str(config.get("question") or "").strip()
196229
else ["'question' is required for doubao_research channel"]
197230
)
231+
232+
async def fetch(self, ctx: FetchContext) -> FetchResult:
233+
"""Thick-contract entry point: migrate doubao onto the runner protocol
234+
(``type(chan).fetch is not AbstractChannel.fetch``) and own a bounded
235+
retry on TRANSIENT faults only.
236+
237+
``collect()`` stays the single source of truth for prompt construction
238+
and evidence output; this override adds the retry loop around it:
239+
- captcha walls (``captcha_challenge``) are never auto-retried — the
240+
pipeline's captcha branch (PR #65) pauses the source for a human
241+
instead;
242+
- non-retryable failures (``error_taxonomy.is_retryable`` False) fail
243+
immediately;
244+
- transient faults (CDP races classified as ``ConnectionError``,
245+
timeouts) retry with exponential backoff up to ``max_retries``
246+
(default 3, config key ``max_retries``; ``retry_base_delay``
247+
seconds, default 2).
248+
249+
``ctx.http`` is deliberately not threaded into ``collect()`` — the
250+
transport is a local opencli subprocess, not an HTTP request the
251+
runner's RateLimitedClient was built for (same trade-off documented in
252+
``opencli_channel.fetch()``).
253+
"""
254+
import asyncio
255+
256+
from backend.pipeline.error_taxonomy import is_retryable
257+
258+
# Local captcha marker (equals error_taxonomy.CAPTCHA_CHALLENGE, which
259+
# PR #65 exposes as is_captcha()) — kept inline so this PR merges
260+
# independently of #65.
261+
_CAPTCHA = "captcha_challenge"
262+
263+
max_retries = int(ctx.config.get("max_retries", 3))
264+
base_delay = float(ctx.config.get("retry_base_delay", 2.0))
265+
last: ChannelResult | None = None
266+
for attempt in range(max_retries + 1):
267+
result = await self.collect(ctx.config, ctx.params)
268+
if result.success:
269+
return FetchResult(items=result.items, metadata=result.metadata)
270+
if result.error_type == _CAPTCHA or not is_retryable(result.error_type):
271+
raise ChannelFetchError(
272+
result.error or "doubao collect failed",
273+
error_type=result.error_type,
274+
)
275+
last = result
276+
if attempt < max_retries:
277+
await asyncio.sleep(base_delay * (2**attempt))
278+
raise ChannelFetchError(
279+
last.error or "doubao collect failed",
280+
error_type=last.error_type,
281+
)

‎backend/channels/douyin_detail_channel.py‎

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,28 @@
55
from typing import Any
66
from urllib.parse import urlparse
77

8-
from backend.channels.base import AbstractChannel, Capabilities, ChannelResult
8+
from backend.channels.base import (
9+
AbstractChannel,
10+
Capabilities,
11+
ChannelFetchError,
12+
ChannelResult,
13+
FetchContext,
14+
FetchResult,
15+
)
916
from backend.channels.registry import register_channel
1017

18+
#: Transient CDP/browser race conditions — retrying the same video lookup
19+
#: usually succeeds once navigation settles. Classified as ``ConnectionError``
20+
#: (retryable in error_taxonomy) so the thick ``fetch()`` retry loop picks
21+
#: them up.
22+
_TRANSIENT_CDP_MARKERS = (
23+
"CDP connection is not open",
24+
"Inspected target navigated or closed",
25+
"Execution context was destroyed",
26+
"Target closed",
27+
"Session closed",
28+
)
29+
1130
_AWEME_ID_RE = re.compile(r"(?:/video/|/share/video/|[?&]aweme_id=)(\d{15,25})(?:[/?&#]|$)")
1231

1332

@@ -96,6 +115,13 @@ def _parse_detail(stdout: str) -> dict[str, Any]:
96115
return detail
97116

98117

118+
def _is_transient_cdp_fault(stderr: str, stdout: str) -> bool:
119+
"""True when the adapter hit a CDP/browser race (navigation, closed tab,
120+
session teardown) — retrying the same lookup usually succeeds."""
121+
text = f"{stderr} {stdout}"
122+
return any(marker in text for marker in _TRANSIENT_CDP_MARKERS)
123+
124+
99125
async def _run_douyin_command(command: list[str]) -> tuple[int, str, str]:
100126
"""Use OpenCLI's bounded subprocess helper rather than spawning a shell."""
101127
import os
@@ -139,7 +165,10 @@ async def collect(self, config: dict[str, Any], parameters: dict[str, Any]) -> C
139165
)
140166
if open_code:
141167
return ChannelResult.fail(
142-
f"OpenCLI browser open exited with code {open_code}: {open_stderr[:500]}"
168+
f"OpenCLI browser open exited with code {open_code}: {open_stderr[:500]}",
169+
error_type=(
170+
"ConnectionError" if _is_transient_cdp_fault(open_stderr, "") else None
171+
),
143172
)
144173
eval_code, stdout, stderr = await _run_douyin_command(
145174
[
@@ -165,7 +194,10 @@ async def collect(self, config: dict[str, Any], parameters: dict[str, Any]) -> C
165194

166195
if eval_code:
167196
return ChannelResult.fail(
168-
f"OpenCLI browser eval exited with code {eval_code}: {stderr[:500]}"
197+
f"OpenCLI browser eval exited with code {eval_code}: {stderr[:500]}",
198+
error_type=(
199+
"ConnectionError" if _is_transient_cdp_fault(stderr, stdout) else None
200+
),
169201
)
170202
try:
171203
item = _detail_item(_parse_detail(stdout), aweme_id)
@@ -185,3 +217,36 @@ async def validate_config(self, config: dict[str, Any]) -> list[str]:
185217
def identity(self, item: dict[str, Any]) -> str | None:
186218
value = item.get("aweme_id")
187219
return str(value) if value else None
220+
221+
async def fetch(self, ctx: FetchContext) -> FetchResult:
222+
"""Thick-contract entry point: migrate douyin onto the runner protocol
223+
and own a bounded retry on transient CDP faults (same pattern as
224+
``doubao_research_channel.fetch()`` — see its docstring for the
225+
rationale). ``collect()`` stays the single source of truth; this
226+
override only adds the retry loop for ``error_taxonomy``-retryable
227+
failures (``max_retries`` default 3, ``retry_base_delay`` default 2s,
228+
exponential backoff). Permanent failures fail immediately.
229+
"""
230+
import asyncio
231+
232+
from backend.pipeline.error_taxonomy import is_retryable
233+
234+
max_retries = int(ctx.config.get("max_retries", 3))
235+
base_delay = float(ctx.config.get("retry_base_delay", 2.0))
236+
last: ChannelResult | None = None
237+
for attempt in range(max_retries + 1):
238+
result = await self.collect(ctx.config, ctx.params)
239+
if result.success:
240+
return FetchResult(items=result.items, metadata=result.metadata)
241+
if not is_retryable(result.error_type):
242+
raise ChannelFetchError(
243+
result.error or "douyin detail collect failed",
244+
error_type=result.error_type,
245+
)
246+
last = result
247+
if attempt < max_retries:
248+
await asyncio.sleep(base_delay * (2**attempt))
249+
raise ChannelFetchError(
250+
last.error or "douyin detail collect failed",
251+
error_type=last.error_type,
252+
)

‎tests/unit/channels/test_doubao_research_channel.py‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22

3+
from backend.channels.base import ChannelFetchError, ChannelResult, FetchContext
34
from backend.channels.doubao_research_channel import (
45
DoubaoResearchChannel,
56
_citations,
@@ -148,3 +149,129 @@ def test_source_schema_accepts_doubao_research_channel():
148149
)
149150
assert source.channel_type == "doubao_research"
150151

152+
153+
# ── thick fetch(): bounded retry on transient faults (PR-thick-fetch) ────
154+
155+
156+
def _ctx(**config) -> FetchContext:
157+
cfg = {"question": "x", "max_retries": 3, "retry_base_delay": 0.001}
158+
cfg.update(config)
159+
return FetchContext(config=cfg, params={})
160+
161+
162+
@pytest.mark.asyncio
163+
async def test_fetch_retries_transient_then_succeeds(monkeypatch):
164+
channel = DoubaoResearchChannel()
165+
results = [
166+
ChannelResult.fail("CDP connection is not open", error_type="ConnectionError"),
167+
ChannelResult.fail("CDP connection is not open", error_type="ConnectionError"),
168+
ChannelResult.ok([{"title": "回答"}]),
169+
]
170+
calls: list[int] = []
171+
172+
async def fake_collect(config, parameters):
173+
calls.append(1)
174+
return results.pop(0)
175+
176+
monkeypatch.setattr(channel, "collect", fake_collect)
177+
out = await channel.fetch(_ctx())
178+
179+
assert out.items == [{"title": "回答"}]
180+
assert len(calls) == 3 # 2 failures + 1 success
181+
182+
183+
@pytest.mark.asyncio
184+
async def test_fetch_gives_up_after_max_retries(monkeypatch):
185+
channel = DoubaoResearchChannel()
186+
calls: list[int] = []
187+
188+
async def fake_collect(config, parameters):
189+
calls.append(1)
190+
return ChannelResult.fail("CDP connection is not open", error_type="ConnectionError")
191+
192+
monkeypatch.setattr(channel, "collect", fake_collect)
193+
194+
with pytest.raises(ChannelFetchError) as ei:
195+
await channel.fetch(_ctx(max_retries=3))
196+
197+
assert ei.value.error_type == "ConnectionError"
198+
assert len(calls) == 4 # initial + 3 retries
199+
200+
201+
@pytest.mark.asyncio
202+
async def test_fetch_does_not_retry_captcha(monkeypatch):
203+
channel = DoubaoResearchChannel()
204+
calls: list[int] = []
205+
206+
async def fake_collect(config, parameters):
207+
calls.append(1)
208+
return ChannelResult.fail("verification challenge", error_type="captcha_challenge")
209+
210+
monkeypatch.setattr(channel, "collect", fake_collect)
211+
212+
with pytest.raises(ChannelFetchError) as ei:
213+
await channel.fetch(_ctx())
214+
215+
assert ei.value.error_type == "captcha_challenge"
216+
assert len(calls) == 1 # captcha must never auto-retry
217+
218+
219+
@pytest.mark.asyncio
220+
async def test_fetch_does_not_retry_permanent_errors(monkeypatch):
221+
channel = DoubaoResearchChannel()
222+
calls: list[int] = []
223+
224+
async def fake_collect(config, parameters):
225+
calls.append(1)
226+
return ChannelResult.fail("bad config", error_type="ValueError")
227+
228+
monkeypatch.setattr(channel, "collect", fake_collect)
229+
230+
with pytest.raises(ChannelFetchError) as ei:
231+
await channel.fetch(_ctx())
232+
233+
assert ei.value.error_type == "ValueError"
234+
assert len(calls) == 1
235+
236+
237+
@pytest.mark.asyncio
238+
async def test_fetch_success_passes_metadata_through(monkeypatch):
239+
channel = DoubaoResearchChannel()
240+
async def fake_collect(config, parameters):
241+
return ChannelResult.ok(
242+
[{"title": "x"}], citation_count=3, citation_capture="answer_url_extraction"
243+
)
244+
245+
monkeypatch.setattr(channel, "collect", fake_collect)
246+
out = await channel.fetch(_ctx())
247+
248+
assert out.metadata["citation_count"] == 3
249+
assert out.metadata["citation_capture"] == "answer_url_extraction"
250+
251+
252+
# ── transient CDP classification in collect (PR-thick-fetch) ─────────────
253+
254+
255+
@pytest.mark.asyncio
256+
async def test_collect_classifies_cdp_transient_as_connection_error(monkeypatch):
257+
async def fake_run(command):
258+
return 1, "", "error: CDP connection is not open"
259+
260+
monkeypatch.setattr("backend.channels.doubao_research_channel._run_doubao_command", fake_run)
261+
result = await DoubaoResearchChannel().collect({"question": "测试"}, {})
262+
263+
assert not result.success
264+
assert result.error_type == "ConnectionError" # retryable per error_taxonomy
265+
266+
267+
@pytest.mark.asyncio
268+
async def test_collect_classifies_target_navigated_as_connection_error(monkeypatch):
269+
async def fake_run(command):
270+
return 1, "", "Inspected target navigated or closed"
271+
272+
monkeypatch.setattr("backend.channels.doubao_research_channel._run_doubao_command", fake_run)
273+
result = await DoubaoResearchChannel().collect({"question": "测试"}, {})
274+
275+
assert not result.success
276+
assert result.error_type == "ConnectionError"
277+

0 commit comments

Comments
 (0)