feat(local): say so when a host is turned off during a reply

A Claude Code run stopped at once when a host it needs was turned off
mid-reply, but the chat called it unknown: chat had an error kind for
an egress refusal only before a reply. It now sorts EgressDeniedError
into egress_disabled, which says the host was turned off in Settings >
Network and to allow it there, in every language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anish Sarkar 2026-10-16 20:58:24 +05:30
parent c4763ae362
commit e95b8d3ed6
19 changed files with 80 additions and 4 deletions

View file

@ -104,7 +104,7 @@ The list handed to the generator is `[system, *history within budget, user]`.
- Every frame is `id: <seq>\ndata: {json}\n\n`, numbered from 1 within its run, and the stream ends with `data: [DONE]\n\n` so a client can tell completion from a dropped connection. The response sends `Cache-Control: no-cache` and `X-Accel-Buffering: no` so nothing buffers it into one late blob.
- The trace comes from the provider's `chat_deltas()`, which marks each chunk as answer or reasoning ([`connections.md`](connections.md#runtime)). It never reaches `resolve_citations()`, so a `[n]` inside it is neither rewritten nor stored as a citation.
- Errors are sorted by exception type, HTTP status and provider, and for a 400 by the body's `error.type` ([`errors.py`](../../surfsense_local/backend/modules/chat/errors.py)), into `provider_auth`, `provider_not_found`, `provider_rate_limited`, `provider_unavailable`, `model_cannot_run`, `context_too_long`, `subscription_sign_in`, `subscription_limit`, `claude_sign_in`, `claude_limit`, `runtime_busy`, `network`, `timeout` and `unknown`, each with a plain-language English message. `runtime_busy` is the local runtime's shared cache running out of room under replies running together, which llama.cpp reports as a `500` like a model that failed to load; admission makes it rare, and it offers Retry ([admission](local-models/admission.md)). The two `subscription_` kinds come from a ChatGPT connection, in a chat or an agent thread: its account has to sign in again, or its plan's usage limit is reached, which no retry fixes ([`chatgpt-subscription.md`](chatgpt-subscription.md)). The two `claude_` kinds are a Claude plan's, sorted before them since they are the same errors' subclasses: Claude Code on this computer has to be installed or signed in again, in a terminal rather than in Model setup, or the plan's usage limit is reached. A subscription's own `429` or 5xx is retried twice first, after the wait it asks for and at most a minute. The frontend shows its own translated text per kind ([`chat-error-text.ts`](../../surfsense_local/frontend/src/features/chat/chat-error-text.ts)); a pre-stream `unknown` failure keeps an informative error message inside a translated sentence, and an unrecognized future kind falls back to the backend's message.
- Errors are sorted by exception type, HTTP status and provider, and for a 400 by the body's `error.type` ([`errors.py`](../../surfsense_local/backend/modules/chat/errors.py)), into `provider_auth`, `provider_not_found`, `provider_rate_limited`, `provider_unavailable`, `model_cannot_run`, `context_too_long`, `subscription_sign_in`, `subscription_limit`, `claude_sign_in`, `claude_limit`, `egress_disabled`, `runtime_busy`, `network`, `timeout` and `unknown`, each with a plain-language English message. `runtime_busy` is the local runtime's shared cache running out of room under replies running together, which llama.cpp reports as a `500` like a model that failed to load; admission makes it rare, and it offers Retry ([admission](local-models/admission.md)). The two `subscription_` kinds come from a ChatGPT connection, in a chat or an agent thread: its account has to sign in again, or its plan's usage limit is reached, which no retry fixes ([`chatgpt-subscription.md`](chatgpt-subscription.md)). The two `claude_` kinds are a Claude plan's, sorted before them since they are the same errors' subclasses: Claude Code on this computer has to be installed or signed in again, in a terminal rather than in Model setup, or the plan's usage limit is reached. `egress_disabled` is a host turned off in Settings › Network while a reply runs, as a Claude Code run sees it; refused before a reply, the same refusal is a `403` the egress prompt answers ([egress](egress.md#asking-for-consent)). A subscription's own `429` or 5xx is retried twice first, after the wait it asks for and at most a minute. The frontend shows its own translated text per kind ([`chat-error-text.ts`](../../surfsense_local/frontend/src/features/chat/chat-error-text.ts)); a pre-stream `unknown` failure keeps an informative error message inside a translated sentence, and an unrecognized future kind falls back to the backend's message.
## Runs

View file

@ -89,7 +89,7 @@ Claude Code makes its own HTTPS calls, which no `egress.require()` in the API wo
**Where both hosts are required.** `allowed_connection()`, which the model list, choosing a model, the chat check and the catalog go through; `_connection()` in `resolution.py`, before chat, titles and Studio generate; and creating the connection. Each raises one refusal naming every host still off, with `purpose: "claude_plan"`, which the prompt words its question by. Deleting the connection forgets `host:platform.claude.com` too, since no connection's address names it.
**A host turned off mid-reply.** A refused tunnel does not fail the request: Claude Code retries up to 10 times, the first after about a second, each reported as a `system` message of subtype `api_retry`. On the first, the provider reads the two destinations again, and when one is off the run ends at once with that refusal.
**A host turned off mid-reply.** A refused tunnel does not fail the request: Claude Code retries up to 10 times, the first after about a second, each reported as a `system` message of subtype `api_retry`. On the first, the provider reads the two destinations again, and when one is off the run ends at once with that refusal, which the chat shows as `egress_disabled`: turned off in Settings › Network, allow it there and try again.
## Errors

View file

@ -132,5 +132,4 @@ A SurfSense sign-in to Claude, reading or storing Claude Code's credentials, `cl
- Windows and Linux on a real install: whether `claude auth status` prints the same JSON, whether `HTTPS_PROXY` is honoured the same way, and WinGet's `Links` folder. Where installers put `claude` is settled ([`01-connection.md`](01-connection.md#finding-claude-code)) and tested on every system with stand-ins.
- The lowest Claude Code version to accept, and how often a contract test against the real binary runs.
- Whether a plan limit reached with Claude Code's extra usage switched on should say so; `overageStatus` is in the `RateLimitEvent`.
- A host turned off while a reply streams ends the run at once, but the chat shows `unknown`: chat has no error kind for an egress refusal mid-reply, only before it. A Known gap until one is added.
- Whether transcripts the agent engine writes under `~/.claude/projects` should be deleted once mirrored ([`03-agent.md`](03-agent.md#sessions)).

View file

@ -2,6 +2,7 @@ import enum
import httpx
from modules.egress.service import EgressDeniedError
from modules.llm.providers.openai_responses import PlanLimitError, SignInRequiredError
from modules.llm.subscriptions.claude.errors import (
ClaudePlanLimitError,
@ -27,6 +28,7 @@ class ChatErrorKind(enum.StrEnum):
SUBSCRIPTION_LIMIT = "subscription_limit"
CLAUDE_SIGN_IN = "claude_sign_in"
CLAUDE_LIMIT = "claude_limit"
EGRESS_DISABLED = "egress_disabled"
RUNTIME_BUSY = "runtime_busy"
NETWORK = "network"
TIMEOUT = "timeout"
@ -65,6 +67,10 @@ _MESSAGES: dict[ChatErrorKind, str] = {
ChatErrorKind.CLAUDE_LIMIT: (
"Your Claude plan's usage limit is reached. It resets on its own."
),
ChatErrorKind.EGRESS_DISABLED: (
"A host this model needs was turned off in Settings > Network. "
"Allow it there, then try again."
),
ChatErrorKind.RUNTIME_BUSY: (
"The replies running together ran out of room in the local model's "
"memory. Try again."
@ -117,6 +123,11 @@ def classify_chat_error(exc: Exception, provider: str) -> tuple[ChatErrorKind, s
# key is unrecoverable once this install's secret changes, so the only
# useful answer names the key rather than reporting a fault.
return ChatErrorKind.PROVIDER_AUTH, _MESSAGES[ChatErrorKind.PROVIDER_AUTH]
# Refused before a reply, this is a 403 the egress prompt answers; refused
# during one, as a Claude Code run is when a host is turned off, it is here.
if isinstance(exc, EgressDeniedError):
kind = ChatErrorKind.EGRESS_DISABLED
return kind, _MESSAGES[kind]
# The Claude plan's first: they are the ChatGPT plan's errors too.
if isinstance(exc, ClaudeSignInRequiredError):
kind = ChatErrorKind.CLAUDE_SIGN_IN

View file

@ -53,7 +53,9 @@ class FakeClaude:
text = prompt if isinstance(prompt, str) else prompt[-1]["text"]
if text.startswith("Write a 2-5 word"):
return answer("A title")
return self.replies.pop(0)
reply = self.replies.pop(0)
# A reply can act when it is sent, as something changing mid-run.
return reply() if callable(reply) else reply
async def open(self, options: object) -> ScriptedRun:
run = self.runs.pop(0) if self.runs else ScriptedRun(respond=self._respond)

View file

@ -1,6 +1,7 @@
"""A Claude plan model answers the app's chat, its chat check, and every resolver."""
import json
import threading
import pytest
from httpx import AsyncClient
@ -240,3 +241,37 @@ async def test_no_claude_code_runs_without_the_proxy(
assert [f["type"] for f in frames if f.get("type") == "error"] == ["error"]
assert len(fake_claude.opened) == opened
@pytest.mark.usefixtures("real_model")
async def test_a_host_turned_off_during_a_reply_says_so(
client: AsyncClient,
engine: Engine,
claude_on_this_machine: ClaudeOnThisMachine,
fake_claude: FakeClaude,
) -> None:
"""Checked before the reply and passed; switched off while Claude Code runs."""
from claude_agent_sdk import SystemMessage
from modules.egress.service import set_enabled
await _choose_claude(client, claude_on_this_machine)
def switch_off() -> None:
with create_session_factory(engine)() as session:
set_enabled(session, "host:api.anthropic.com", False)
session.commit()
def switched_off_mid_run() -> list:
# Off the event loop, as Settings > Network's own request would be.
flip = threading.Thread(target=switch_off)
flip.start()
flip.join()
return [SystemMessage(subtype="api_retry", data={"attempt": 1}), *answer("x")]
fake_claude.replies.append(switched_off_mid_run)
frames = await _send(client, await _basic_thread(client), "Hi")
errors = [f for f in frames if f.get("type") == "error"]
assert [e["kind"] for e in errors] == ["egress_disabled"]

View file

@ -184,3 +184,15 @@ def test_a_used_up_claude_plan_says_whose_limit_it_is() -> None:
assert kind is ChatErrorKind.CLAUDE_LIMIT
assert "Claude" in message
assert "ChatGPT" not in message
def test_a_host_turned_off_during_a_reply_says_where_to_allow_it() -> None:
"""Not `unknown`: the fix is a switch in Settings, which the user can flip."""
from modules.egress.service import EgressDeniedError
kind, message = classify_chat_error(
EgressDeniedError("host:api.anthropic.com"), "openai_compatible"
)
assert kind is ChatErrorKind.EGRESS_DISABLED
assert "Settings > Network" in message

View file

@ -62,6 +62,12 @@ const chatErrorText: Record<ChatTurnErrorKind, () => string> = {
defaultMessage:
"Your Claude plan’s usage limit is reached. It resets on its own.",
}),
egress_disabled: () =>
intl.formatMessage({
id: "chat_error_egress_disabled",
defaultMessage:
"A host this model needs was turned off in Settings › Network. Allow it there, then try again.",
}),
network: () =>
intl.formatMessage({
id: "chat_error_network",

View file

@ -67,6 +67,7 @@ export type ChatErrorKind =
| "subscription_limit"
| "claude_sign_in"
| "claude_limit"
| "egress_disabled"
| "runtime_busy"
| "network"
| "timeout"

View file

@ -136,6 +136,7 @@
"chat_error_claude_limit": "Das Nutzungslimit deines Claude-Plans ist erreicht. Es setzt sich von selbst zurück.",
"chat_error_claude_sign_in": "Claude Code auf diesem Computer muss sich erneut anmelden: Führe claude in einem Terminal aus und melde dich an.",
"chat_error_context_too_long": "Diese Unterhaltung ist zu lang für das Kontextfenster des Modells. Starte einen neuen Chat oder wähle ein Modell mit größerem Fenster.",
"chat_error_egress_disabled": "Ein Host, den dieses Modell braucht, wurde unter Einstellungen › Netzwerk ausgeschaltet. Erlaube ihn dort und versuch es dann erneut.",
"chat_error_interrupted": "Unterbrochen, als die App geschlossen wurde.",
"chat_error_model_cannot_run": "SurfSense kann dieses Modell nicht ausführen. Wähle ein anderes Modell.",
"chat_error_network": "Der Modellanbieter ist nicht erreichbar – prüfe die URL der Verbindung unter „Modell-Einrichtung“.",

View file

@ -136,6 +136,7 @@
"chat_error_claude_limit": "Your Claude plan’s usage limit is reached. It resets on its own.",
"chat_error_claude_sign_in": "Claude Code on this computer needs to sign in again: run claude in a terminal and sign in.",
"chat_error_context_too_long": "This conversation is too long for the model’s context window. Start a new chat or pick a model with a larger window.",
"chat_error_egress_disabled": "A host this model needs was turned off in Settings › Network. Allow it there, then try again.",
"chat_error_interrupted": "Interrupted when the app closed.",
"chat_error_model_cannot_run": "SurfSense cannot run this model. Pick another model.",
"chat_error_network": "Couldn’t reach the model provider — check the connection’s URL in Model setup.",

View file

@ -136,6 +136,7 @@
"chat_error_claude_limit": "Has alcanzado el límite de uso de tu plan de Claude. Se restablece solo.",
"chat_error_claude_sign_in": "Claude Code en este equipo tiene que volver a iniciar sesión: ejecuta claude en una terminal e inicia sesión.",
"chat_error_context_too_long": "Esta conversación es demasiado larga para la ventana de contexto del modelo. Inicia un chat nuevo o elige un modelo con una ventana más grande.",
"chat_error_egress_disabled": "Se desactivó en Ajustes › Red un host que necesita este modelo. Permítelo ahí e inténtalo de nuevo.",
"chat_error_interrupted": "Se interrumpió al cerrarse la aplicación.",
"chat_error_model_cannot_run": "SurfSense no puede ejecutar este modelo. Elige otro modelo.",
"chat_error_network": "No se pudo conectar con el proveedor de modelos: comprueba la URL de la conexión en Ajustes del modelo.",

View file

@ -136,6 +136,7 @@
"chat_error_claude_limit": "La limite d’utilisation de votre forfait Claude est atteinte. Elle se réinitialise d’elle-même.",
"chat_error_claude_sign_in": "Claude Code sur cet ordinateur doit se reconnecter : exécutez claude dans un terminal et connectez-vous.",
"chat_error_context_too_long": "Cette conversation est trop longue pour la fenêtre de contexte du modèle. Démarrez un nouveau chat ou choisissez un modèle avec une fenêtre plus grande.",
"chat_error_egress_disabled": "Un hôte dont ce modèle a besoin a été désactivé dans Paramètres › Réseau. Autorisez-le à cet endroit, puis réessayez.",
"chat_error_interrupted": "Interrompu à la fermeture de l’application.",
"chat_error_model_cannot_run": "SurfSense ne peut pas exécuter ce modèle. Choisissez un autre modèle.",
"chat_error_network": "Impossible de joindre le fournisseur de modèle — vérifiez l’URL de la connexion dans Configuration du modèle.",

View file

@ -136,6 +136,7 @@
"chat_error_claude_limit": "आपके Claude प्लान की उपयोग सीमा पूरी हो गई है। यह अपने आप रीसेट हो जाती है।",
"chat_error_claude_sign_in": "इस कंप्यूटर पर Claude Code को फिर से साइन इन करना होगा: किसी टर्मिनल में claude चलाएं और साइन इन करें।",
"chat_error_context_too_long": "यह बातचीत मॉडल की कॉन्टेक्स्ट विंडो के लिए बहुत लंबी है। नई चैट शुरू करें या बड़ी विंडो वाला मॉडल चुनें।",
"chat_error_egress_disabled": "इस मॉडल को जिस होस्ट की ज़रूरत है, उसे सेटिंग्स › नेटवर्क में बंद कर दिया गया है। वहां उसे अनुमति दें, फिर से कोशिश करें।",
"chat_error_interrupted": "ऐप बंद होने पर जवाब रुक गया।",
"chat_error_model_cannot_run": "SurfSense यह मॉडल नहीं चला सकता। कोई दूसरा मॉडल चुनें।",
"chat_error_network": "मॉडल प्रोवाइडर तक नहीं पहुंच सके — मॉडल सेटअप में कनेक्शन का URL जांचें।",

View file

@ -136,6 +136,7 @@
"chat_error_claude_limit": "Claudeプランの利用上限に達しました。上限は自動的にリセットされます。",
"chat_error_claude_sign_in": "このコンピューターのClaude Codeで再度サインインする必要があります。ターミナルでclaudeを実行してサインインしてください。",
"chat_error_context_too_long": "この会話はモデルのコンテキストウィンドウに収まらない長さです。新しいチャットを始めるか、より大きなウィンドウのモデルを選んでください。",
"chat_error_egress_disabled": "このモデルに必要なホストが設定 › ネットワークでオフにされました。そこで許可してから、もう一度お試しください。",
"chat_error_interrupted": "アプリが閉じられたため中断されました。",
"chat_error_model_cannot_run": "SurfSenseではこのモデルを実行できません。別のモデルを選んでください。",
"chat_error_network": "モデルプロバイダーに接続できませんでした。モデル設定で接続先のURLを確認してください。",

View file

@ -136,6 +136,7 @@
"chat_error_claude_limit": "Claude 플랜의 사용 한도에 도달했습니다. 한도는 자동으로 초기화됩니다.",
"chat_error_claude_sign_in": "이 컴퓨터의 Claude Code에 다시 로그인해야 합니다. 터미널에서 claude를 실행하고 로그인하세요.",
"chat_error_context_too_long": "이 대화는 모델의 컨텍스트 창에 담기에 너무 깁니다. 새 채팅을 시작하거나 더 큰 창을 가진 모델을 선택하세요.",
"chat_error_egress_disabled": "이 모델에 필요한 호스트가 설정 › 네트워크에서 꺼졌습니다. 그곳에서 허용한 후 다시 시도하세요.",
"chat_error_interrupted": "앱이 종료되어 중단되었습니다.",
"chat_error_model_cannot_run": "SurfSense는 이 모델을 실행할 수 없습니다. 다른 모델을 선택하세요.",
"chat_error_network": "모델 제공업체에 연결할 수 없습니다 — 모델 설정에서 연결 URL을 확인하세요.",

View file

@ -136,6 +136,7 @@
"chat_error_claude_limit": "O limite de uso do seu plano do Claude foi atingido. Ele é redefinido sozinho.",
"chat_error_claude_sign_in": "O Claude Code neste computador precisa entrar novamente: execute claude em um terminal e entre.",
"chat_error_context_too_long": "Esta conversa é longa demais para a janela de contexto do modelo. Inicie um novo chat ou escolha um modelo com uma janela maior.",
"chat_error_egress_disabled": "Um host de que este modelo precisa foi desativado em Configurações › Rede. Permita-o lá e tente novamente.",
"chat_error_interrupted": "Interrompida quando o app foi fechado.",
"chat_error_model_cannot_run": "O SurfSense não consegue executar este modelo. Escolha outro modelo.",
"chat_error_network": "Não foi possível acessar o provedor de modelos — verifique a URL da conexão em Configuração de modelos.",

View file

@ -136,6 +136,7 @@
"chat_error_claude_limit": "Достигнут лимит использования вашего плана Claude. Он сбрасывается сам.",
"chat_error_claude_sign_in": "Claude Code на этом компьютере нужно снова войти: запустите claude в терминале и войдите.",
"chat_error_context_too_long": "Этот разговор слишком длинный для контекстного окна модели. Начните новый чат или выберите модель с окном большего размера.",
"chat_error_egress_disabled": "Хост, который нужен этой модели, отключён в разделе Настройки › Сеть. Разрешите его там и попробуйте ещё раз.",
"chat_error_interrupted": "Прервано при закрытии приложения.",
"chat_error_model_cannot_run": "SurfSense не может запустить эту модель. Выберите другую модель.",
"chat_error_network": "Не удалось связаться с провайдером модели — проверьте URL подключения в разделе «Настройка модели».",

View file

@ -136,6 +136,7 @@
"chat_error_claude_limit": "你的 Claude 套餐已达到使用上限。上限会自动重置。",
"chat_error_claude_sign_in": "这台电脑上的 Claude Code 需要重新登录:在终端中运行 claude 并登录。",
"chat_error_context_too_long": "这段对话太长,超出了模型的上下文窗口。请开始新对话,或选择上下文窗口更大的模型。",
"chat_error_egress_disabled": "这个模型需要的一个主机已在“设置 › 网络”中关闭。请在那里允许它,然后重试。",
"chat_error_interrupted": "应用关闭时已中断。",
"chat_error_model_cannot_run": "SurfSense 无法运行这个模型。请选择其他模型。",
"chat_error_network": "无法连接模型提供商,请在“模型设置”中检查连接的 URL。",