free-claude-code/core/anthropic/streaming/recovery.py
Ali Khokhar 85b601884d
Some checks are pending
CI / Ban suppressions and legacy annotations (push) Waiting to run
CI / pytest (push) Waiting to run
CI / ruff-check (push) Waiting to run
CI / ruff-format (push) Waiting to run
CI / ty (push) Waiting to run
Remove legacy future annotation imports (#982)
## Problem

Python 3.14 provides native lazy annotations, but the codebase still
relied on legacy future annotation imports. Those imports also made
type-only import cycles easier to hide instead of fixing ownership
boundaries.

## Changes

| Before | After |
| --- | --- |
| Python files used `from __future__ import annotations`. | Python files
rely on Python 3.14 native lazy annotations. |
| Some runtime modules used `TYPE_CHECKING` or local imports for
required dependencies. | Runtime modules use top-level owner-module
imports with explicit boundaries. |
| Local and GitHub guardrails only rejected type ignore suppressions. |
Local and GitHub guardrails reject type ignore suppressions and legacy
future annotation imports. |
| Agent docs only documented the no-type-ignore rule. | Agent docs
document the Python 3.14 annotation and import-boundary rules. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR moves the codebase to Python 3.14 native lazy annotations. The
main changes are:

- Removed legacy `from __future__ import annotations` imports across
Python modules.
- Promoted selected runtime dependencies from `TYPE_CHECKING` or local
imports to explicit owner-module imports.
- Added local, GitHub, and contract-test guardrails to reject legacy
future annotation imports.
- Updated agent docs with the annotation and import-boundary rules.
- Bumped the package patch version for production-file changes.
</details>

<h3>Confidence Score: 5/5</h3>

Safe to merge with low risk.

The changes are mostly mechanical annotation cleanup with matching CI
and contract-test guardrails. Reviewed import-boundary updates did not
show a confirmed runtime cycle or dependency break.

No files require special attention.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- Performed an end-to-end validation of the guardrail contract suite: an
environment check confirmed uv availability, a guardrail pytest run used
CPython 3.14.0 with 5 passing contract tests, 3 focused CI-script tests
passed, and the direct CI suppressions guardrail command (including the
legacy future-annotations grep) also passed.

<a
href="https://app.greptile.com/trex/runs/13303335/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| api/runtime.py | Moves messaging, CLI manager, session, limiter, and
tree dependencies from local/type-checking imports to explicit top-level
owner-module imports. |
| messaging/platforms/telegram.py | Removes future annotations and
promotes Telegram SDK type imports into the existing availability guard.
|
| messaging/platforms/telegram_inbound.py | Removes future annotations
and imports Telegram SDK types at module scope for inbound
normalization. |
| tests/contracts/test_import_boundaries.py | Adds an AST contract that
rejects legacy future annotation imports across Python files. |
| scripts/ci.sh | Extends the local suppression check to reject legacy
future annotation imports alongside type-ignore suppressions. |
| scripts/ci.ps1 | Mirrors the local PowerShell CI suppression check for
legacy future annotations. |
| .github/workflows/tests.yml | Renames and broadens the GitHub
guardrail job to reject both type suppressions and legacy future
annotations. |
| pyproject.toml | Bumps the patch version for production-file changes.
|

</details>

<details open><summary><h3>Sequence Diagram</h3></summary>

<a href="#gh-light-mode-only">

```mermaid
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Dev as Developer/CI
participant Guard as Suppression guard
participant AST as Import-boundary contract test
participant Py as Python modules

Dev->>Guard: Run local/GitHub suppression check
Guard->>Py: "Scan *.py for type ignores and future annotations"
Guard-->>Dev: Fail if legacy annotation import remains
Dev->>AST: Run pytest contract tests
AST->>Py: Parse imports with ast
AST-->>Dev: Assert no future annotations/import-boundary violations
Py-->>Dev: Use Python 3.14 native lazy annotations
```

</a>
<a href="#gh-dark-mode-only">

```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Dev as Developer/CI
participant Guard as Suppression guard
participant AST as Import-boundary contract test
participant Py as Python modules

Dev->>Guard: Run local/GitHub suppression check
Guard->>Py: "Scan *.py for type ignores and future annotations"
Guard-->>Dev: Fail if legacy annotation import remains
Dev->>AST: Run pytest contract tests
AST->>Py: Parse imports with ast
AST-->>Dev: Assert no future annotations/import-boundary violations
Py-->>Dev: Use Python 3.14 native lazy annotations
```

</a>
</details>

<sub>Reviews (2): Last reviewed commit: ["Remove legacy future
annotations
import"](6e6cda69da)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=41875785)</sub>

<!-- /greptile_comment -->
2026-07-04 21:41:51 -07:00

422 lines
13 KiB
Python

"""Shared retry and recovery policy for Anthropic streams."""
import json
import time
from collections.abc import Callable
from copy import deepcopy
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
import httpx
import jsonschema
import openai
from loguru import logger
from core.trace import trace_event
from .transient_errors import retryable_transient_status
EARLY_TRANSPARENT_TOTAL_ATTEMPTS = 5
EARLY_TRANSPARENT_MAX_RETRIES = EARLY_TRANSPARENT_TOTAL_ATTEMPTS - 1
MIDSTREAM_RECOVERY_ATTEMPTS = 5
EARLY_HOLDBACK_SECONDS = 0.75
RECOVERY_BUFFER_MAX_BYTES = 65_536
_RECOVERY_USER_PREFIX = (
"The previous provider stream was interrupted. Continue the assistant response "
"exactly where it stopped. Do not repeat text already written."
)
_RECOVERY_THINKING_PREFIX = (
"The assistant had already emitted this hidden thinking before the interruption:\n"
)
class TruncatedProviderStreamError(RuntimeError):
"""Raised internally when an upstream stream ends without a terminal marker."""
class RecoveryFailureAction(StrEnum):
"""How the stream lifecycle should respond to an upstream failure."""
EARLY_RETRY = "early_retry"
MIDSTREAM_RECOVERY = "midstream_recovery"
FINAL_ERROR = "final_error"
@dataclass(frozen=True, slots=True)
class RecoveryDecision:
"""Failure classification result for one stream exception."""
action: RecoveryFailureAction
retryable: bool
committed: bool
has_buffered: bool
early_retry_attempt: int | None = None
midstream_recovery_attempt: int | None = None
@dataclass(frozen=True, slots=True)
class ToolSchema:
"""Tool schema resolved from the original Anthropic request."""
name: str
input_schema: dict[str, Any]
@dataclass(frozen=True, slots=True)
class ToolRepair:
"""Accepted append-only tool JSON repair."""
suffix: str
parsed_input: dict[str, Any]
class RecoveryHoldbackBuffer:
"""Briefly hold downstream SSE so early stream cutoffs can be retried invisibly."""
def __init__(
self,
*,
holdback_seconds: float = EARLY_HOLDBACK_SECONDS,
max_bytes: int = RECOVERY_BUFFER_MAX_BYTES,
now: Callable[[], float] | None = None,
) -> None:
self._holdback_seconds = holdback_seconds
self._max_bytes = max_bytes
self._now = now or time.monotonic
self._events: list[str] = []
self._bytes = 0
self._started_at: float | None = None
self.committed = False
def push(self, event: str) -> list[str]:
if self.committed:
return [event]
if self._started_at is None:
self._started_at = self._now()
self._events.append(event)
self._bytes += len(event.encode("utf-8", errors="replace"))
if (
self._bytes >= self._max_bytes
or self._now() - self._started_at >= self._holdback_seconds
):
return self.flush()
return []
def flush(self) -> list[str]:
if self.committed:
return []
self.committed = True
events = self._events
self._events = []
self._bytes = 0
self._started_at = None
return events
def discard(self) -> None:
self._events = []
self._bytes = 0
self._started_at = None
@property
def has_buffered(self) -> bool:
return bool(self._events)
class RecoveryController:
"""Own holdback and failure classification for one provider stream lifecycle."""
def __init__(self, *, provider_name: str, request_id: str | None) -> None:
self._provider_name = provider_name
self._request_id = request_id
self._holdback = RecoveryHoldbackBuffer()
self._early_retry_count = 0
self._midstream_recovery_count = 0
@property
def committed(self) -> bool:
return self._holdback.committed
@property
def has_buffered(self) -> bool:
return self._holdback.has_buffered
@property
def early_retries(self) -> int:
return self._early_retry_count
@property
def midstream_recoveries(self) -> int:
return self._midstream_recovery_count
def push(self, event: str) -> list[str]:
return self._holdback.push(event)
def flush(self) -> list[str]:
return self._holdback.flush()
def discard(self) -> None:
self._holdback.discard()
def flush_uncommitted(self, decision: RecoveryDecision) -> list[str]:
if not decision.committed and decision.has_buffered:
return self.flush()
return []
def advance_failure(
self,
error: BaseException,
*,
stream_opened: bool,
generated_output: bool,
complete_tool_salvageable: bool,
) -> RecoveryDecision:
retryable = is_retryable_stream_error(error)
committed = self._holdback.committed
has_buffered = self._holdback.has_buffered
if (
retryable
and stream_opened
and not committed
and not complete_tool_salvageable
and self._early_retry_count < EARLY_TRANSPARENT_MAX_RETRIES
):
self._early_retry_count += 1
self._holdback.discard()
self._holdback = RecoveryHoldbackBuffer()
trace_event(
stage="provider",
event="provider.recovery.early_retry",
source="provider",
provider=self._provider_name,
request_id=self._request_id,
retry_attempt=self._early_retry_count,
retryable=True,
)
return RecoveryDecision(
action=RecoveryFailureAction.EARLY_RETRY,
retryable=True,
committed=False,
has_buffered=has_buffered,
early_retry_attempt=self._early_retry_count,
)
if (
retryable
and generated_output
and self._midstream_recovery_count < MIDSTREAM_RECOVERY_ATTEMPTS
):
self._midstream_recovery_count += 1
return RecoveryDecision(
action=RecoveryFailureAction.MIDSTREAM_RECOVERY,
retryable=True,
committed=committed,
has_buffered=has_buffered,
midstream_recovery_attempt=self._midstream_recovery_count,
)
return RecoveryDecision(
action=RecoveryFailureAction.FINAL_ERROR,
retryable=retryable,
committed=committed,
has_buffered=has_buffered,
)
def is_retryable_stream_error(exc: BaseException) -> bool:
"""Return whether a provider stream error can be retried/recovered."""
if isinstance(exc, TruncatedProviderStreamError):
return True
if isinstance(exc, openai.AuthenticationError | openai.BadRequestError):
return False
if retryable_transient_status(exc) is not None:
return True
return isinstance(
exc,
(
TimeoutError,
httpx.ReadTimeout,
httpx.ReadError,
httpx.RemoteProtocolError,
httpx.ConnectError,
httpx.NetworkError,
openai.APITimeoutError,
openai.APIConnectionError,
),
)
def tool_schemas_by_name(request: Any) -> dict[str, ToolSchema]:
"""Return Anthropic tool input schemas keyed by tool name."""
schemas: dict[str, ToolSchema] = {}
tools = getattr(request, "tools", None)
if not tools:
return schemas
for tool in tools:
name = _tool_attr(tool, "name")
if not isinstance(name, str) or not name:
continue
schema = _tool_attr(tool, "input_schema")
if not isinstance(schema, dict):
schema = {"type": "object"}
schemas[name] = ToolSchema(name=name, input_schema=deepcopy(schema))
return schemas
def validate_tool_input(
tool_name: str, parsed_input: dict[str, Any], schemas: dict[str, ToolSchema]
) -> bool:
tool_schema = schemas.get(tool_name)
if tool_schema is None:
return True
try:
validator_cls = jsonschema.validators.validator_for(tool_schema.input_schema)
validator_cls.check_schema(tool_schema.input_schema)
validator_cls(tool_schema.input_schema).validate(parsed_input)
except jsonschema.exceptions.SchemaError as exc:
logger.warning("Skipping invalid tool schema for {}: {}", tool_name, exc)
return True
except jsonschema.exceptions.ValidationError:
return False
return True
def parse_complete_tool_input(
raw_json: str, tool_name: str, schemas: dict[str, ToolSchema]
) -> dict[str, Any] | None:
try:
parsed = json.loads(raw_json)
except json.JSONDecodeError:
return None
if not isinstance(parsed, dict):
return None
if not validate_tool_input(tool_name, parsed, schemas):
return None
return parsed
def accept_tool_json_repair(
prefix: str,
candidate: str,
*,
tool_name: str,
schemas: dict[str, ToolSchema],
) -> ToolRepair | None:
for suffix in _repair_suffix_candidates(prefix, candidate):
combined = prefix + suffix
parsed = parse_complete_tool_input(combined, tool_name, schemas)
if parsed is not None:
return ToolRepair(suffix=suffix, parsed_input=parsed)
return None
def continuation_suffix(existing: str, candidate: str) -> str | None:
existing = existing or ""
candidate = candidate or ""
if not candidate:
return ""
if not existing:
return candidate
if candidate.startswith(existing):
return candidate[len(existing) :]
max_overlap = min(len(existing), len(candidate))
for size in range(max_overlap, 0, -1):
if existing.endswith(candidate[:size]):
return candidate[size:]
if len(candidate) < max(200, len(existing) // 2):
return candidate
return None
def make_text_recovery_body(
body: dict[str, Any],
partial_text: str,
partial_thinking: str = "",
) -> dict[str, Any]:
"""Build a text-only continuation request for either transport family."""
recovery = deepcopy(body)
recovery.pop("tools", None)
recovery.pop("tool_choice", None)
recovery["stream"] = True
messages = _copied_messages(recovery)
if partial_text:
messages.append({"role": "assistant", "content": partial_text})
prompt = _RECOVERY_USER_PREFIX
if partial_thinking:
prompt = f"{_RECOVERY_THINKING_PREFIX}{partial_thinking}\n\n{prompt}"
messages.append({"role": "user", "content": prompt})
recovery["messages"] = messages
return recovery
def make_tool_repair_body(
body: dict[str, Any],
*,
tool_name: str,
prefix: str,
input_schema: dict[str, Any] | None,
) -> dict[str, Any]:
"""Build a text-only request asking for a JSON suffix."""
recovery = deepcopy(body)
recovery.pop("tools", None)
recovery.pop("tool_choice", None)
recovery["stream"] = True
messages = _copied_messages(recovery)
messages.append(
{
"role": "user",
"content": _tool_repair_prompt(
tool_name=tool_name, prefix=prefix, input_schema=input_schema
),
}
)
recovery["messages"] = messages
return recovery
def _tool_attr(tool: Any, attr: str) -> Any:
if isinstance(tool, dict):
return tool.get(attr)
return getattr(tool, attr, None)
def _copied_messages(body: dict[str, Any]) -> list[Any]:
messages = body.get("messages")
return deepcopy(messages) if isinstance(messages, list) else []
def _repair_suffix_candidates(prefix: str, candidate: str) -> list[str]:
raw = candidate.strip()
if not raw:
return []
candidates: list[str] = []
if raw.startswith("```"):
lines = raw.splitlines()
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
raw = "\n".join(lines).strip()
candidates.append(raw)
if raw.startswith(prefix):
candidates.append(raw[len(prefix) :])
return list(dict.fromkeys(candidates))
def _tool_repair_prompt(
*, tool_name: str, prefix: str, input_schema: dict[str, Any] | None
) -> str:
schema_text = json.dumps(input_schema or {"type": "object"}, separators=(",", ":"))
return (
"A streamed tool call was interrupted while writing JSON arguments.\n"
f"Tool name: {tool_name}\n"
f"JSON schema: {schema_text}\n"
f"Already emitted JSON prefix: {prefix}\n\n"
"Return only the exact missing JSON suffix needed to complete the same object. "
"Do not repeat the prefix. Do not include markdown or explanation."
)