refactor: use COPILOT_RETRYABLE_ERROR_PREFIX for server-driven retry

Replace frontend string matching on error text with a dedicated marker
prefix. The backend now uses COPILOT_RETRYABLE_ERROR_PREFIX for
transient errors, and the frontend checks markerType instead of
matching "Anthropic connection interrupted". Also collapses remaining
scattered ternaries and the base URL validation guard.
This commit is contained in:
Zamil Majdy
2026-03-17 13:44:50 +07:00
parent 1565564bce
commit 64d82797b5
4 changed files with 66 additions and 42 deletions

View File

@@ -4,6 +4,9 @@
# The hex suffix makes accidental LLM generation of these strings virtually
# impossible, avoiding false-positive marker detection in normal conversation.
COPILOT_ERROR_PREFIX = "[__COPILOT_ERROR_f7a1__]" # Renders as ErrorCard
COPILOT_RETRYABLE_ERROR_PREFIX = (
"[__COPILOT_RETRYABLE_ERROR_a9c2__]" # ErrorCard + retry
)
COPILOT_SYSTEM_PREFIX = "[__COPILOT_SYSTEM_e3b0__]" # Renders as system info message
# Prefix for all synthetic IDs generated by CoPilot block execution.

View File

@@ -39,6 +39,7 @@ from backend.util.settings import Settings
from ..config import ChatConfig
from ..constants import (
COPILOT_ERROR_PREFIX,
COPILOT_RETRYABLE_ERROR_PREFIX,
COPILOT_SYSTEM_PREFIX,
FRIENDLY_TRANSIENT_MSG,
is_transient_api_error,
@@ -231,18 +232,20 @@ def _build_sdk_env(
}
# --- Mode 2: Direct Anthropic (no proxy hop) ---
# Also the fallback when OpenRouter is enabled but credentials are missing
# or the base URL is invalid.
if not config.use_openrouter or not (config.api_key and config.base_url):
# Also the fallback when OpenRouter is enabled but credentials are missing.
# Strip /v1 suffix — SDK expects the base URL without a version path.
base = (config.base_url or "").rstrip("/")
if base.endswith("/v1"):
base = base[:-3]
if (
not config.use_openrouter
or not config.api_key
or not base
or not base.startswith("http")
):
return {}
# --- Mode 3: OpenRouter proxy ---
base = config.base_url.rstrip("/")
if base.endswith("/v1"):
base = base[:-3]
if not base or not base.startswith("http"):
return {} # malformed base_url — fall back to direct
env: dict[str, str] = {
"ANTHROPIC_BASE_URL": base,
"ANTHROPIC_AUTH_TOKEN": config.api_key,
@@ -1037,19 +1040,20 @@ async def stream_chat_completion_sdk(
# Exception in receive_response() — capture it
# so the session can still be saved and the
# frontend gets a clean finish.
err_str = str(stream_err)
is_transient = is_transient_api_error(err_str)
log = logger.warning if is_transient else logger.error
display = (
FRIENDLY_TRANSIENT_MSG
if is_transient
else f"SDK stream error: {stream_err}"
)
code = (
"transient_api_error"
if is_transient
else "sdk_stream_error"
)
if is_transient_api_error(str(stream_err)):
log, display, code, prefix = (
logger.warning,
FRIENDLY_TRANSIENT_MSG,
"transient_api_error",
COPILOT_RETRYABLE_ERROR_PREFIX,
)
else:
log, display, code, prefix = (
logger.error,
f"SDK stream error: {stream_err}",
"sdk_stream_error",
COPILOT_ERROR_PREFIX,
)
log(
"%s Stream error from SDK: %s",
@@ -1061,7 +1065,7 @@ async def stream_chat_completion_sdk(
session.messages.append(
ChatMessage(
role="assistant",
content=f"{COPILOT_ERROR_PREFIX} {display}",
content=f"{prefix} {display}",
)
)
yield StreamError(errorText=display, code=code)
@@ -1111,7 +1115,7 @@ async def stream_chat_completion_sdk(
session.messages.append(
ChatMessage(
role="assistant",
content=f"{COPILOT_ERROR_PREFIX} {FRIENDLY_TRANSIENT_MSG}",
content=f"{COPILOT_RETRYABLE_ERROR_PREFIX} {FRIENDLY_TRANSIENT_MSG}",
)
)
yield StreamError(
@@ -1226,10 +1230,15 @@ async def stream_chat_completion_sdk(
response.errorText,
response.code,
)
err_prefix = (
COPILOT_RETRYABLE_ERROR_PREFIX
if response.code == "transient_api_error"
else COPILOT_ERROR_PREFIX
)
session.messages.append(
ChatMessage(
role="assistant",
content=f"{COPILOT_ERROR_PREFIX} {response.errorText}",
content=f"{err_prefix} {response.errorText}",
)
)
ended_with_stream_error = True
@@ -1427,8 +1436,14 @@ async def stream_chat_completion_sdk(
else:
logger.error("%s Error: %s", log_prefix, error_msg, exc_info=True)
is_transient = is_transient_api_error(error_msg)
display_msg = FRIENDLY_TRANSIENT_MSG if is_transient else error_msg
if is_transient_api_error(error_msg):
display_msg, code, prefix = (
FRIENDLY_TRANSIENT_MSG,
"transient_api_error",
COPILOT_RETRYABLE_ERROR_PREFIX,
)
else:
display_msg, code, prefix = error_msg, "sdk_error", COPILOT_ERROR_PREFIX
# Append error marker to session (non-invasive text parsing approach)
# The finally block will persist the session with this error marker
@@ -1436,7 +1451,7 @@ async def stream_chat_completion_sdk(
session.messages.append(
ChatMessage(
role="assistant",
content=f"{COPILOT_ERROR_PREFIX} {display_msg}",
content=f"{prefix} {display_msg}",
)
)
logger.debug(
@@ -1450,10 +1465,7 @@ async def stream_chat_completion_sdk(
isinstance(e, RuntimeError) and "cancel scope" in str(e)
)
if not is_cancellation:
yield StreamError(
errorText=display_msg,
code="transient_api_error" if is_transient else "sdk_error",
)
yield StreamError(errorText=display_msg, code=code)
raise
finally:

View File

@@ -65,9 +65,6 @@ function WorkspaceMediaImage(props: React.JSX.IntrinsicElements["img"]) {
/** Stable components override for Streamdown (avoids re-creating on every render). */
const STREAMDOWN_COMPONENTS = { img: WorkspaceMediaImage };
/** Error text that the backend emits for transient Anthropic API failures. */
const TRANSIENT_ERROR_TEXT = "Anthropic connection interrupted";
interface Props {
part: UIMessage<unknown, UIDataTypes, UITools>["parts"][number];
messageID: string;
@@ -89,7 +86,7 @@ export function MessagePartRenderer({
part.text,
);
if (markerType === "error") {
if (markerType === "error" || markerType === "retryable_error") {
const lowerMarker = markerText.toLowerCase();
const isCancellation =
lowerMarker === "operation cancelled" ||
@@ -104,15 +101,12 @@ export function MessagePartRenderer({
</div>
);
}
const isTransient = markerText
.toLowerCase()
.includes(TRANSIENT_ERROR_TEXT.toLowerCase());
return (
<ErrorCard
key={key}
responseError={{ message: markerText }}
context="execution"
onRetry={isTransient ? onRetry : undefined}
onRetry={markerType === "retryable_error" ? onRetry : undefined}
/>
);
}

View File

@@ -172,16 +172,22 @@ export function getTurnMessages(
// The hex suffix makes it virtually impossible for an LLM to accidentally
// produce these strings in normal conversation.
const COPILOT_ERROR_PREFIX = "[__COPILOT_ERROR_f7a1__]";
const COPILOT_RETRYABLE_ERROR_PREFIX = "[__COPILOT_RETRYABLE_ERROR_a9c2__]";
const COPILOT_SYSTEM_PREFIX = "[__COPILOT_SYSTEM_e3b0__]";
export type MarkerType = "error" | "system" | null;
export type MarkerType = "error" | "retryable_error" | "system" | null;
/** Escape all regex special characters in a string. */
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// Pre-compiled marker regexes (avoids re-creating on every call / render)
// Pre-compiled marker regexes (avoids re-creating on every call / render).
// Retryable check must come first since it's more specific.
const RETRYABLE_ERROR_MARKER_RE = new RegExp(
`${escapeRegExp(COPILOT_RETRYABLE_ERROR_PREFIX)}\\s*(.+?)$`,
"s",
);
const ERROR_MARKER_RE = new RegExp(
`${escapeRegExp(COPILOT_ERROR_PREFIX)}\\s*(.+?)$`,
"s",
@@ -196,6 +202,15 @@ export function parseSpecialMarkers(text: string): {
markerText: string;
cleanText: string;
} {
const retryableMatch = text.match(RETRYABLE_ERROR_MARKER_RE);
if (retryableMatch) {
return {
markerType: "retryable_error",
markerText: retryableMatch[1].trim(),
cleanText: text.replace(retryableMatch[0], "").trim(),
};
}
const errorMatch = text.match(ERROR_MARKER_RE);
if (errorMatch) {
return {