fix: Connection closed на каждом HTTP-вызове + улучшения логгинга (v0.6.1)#1
Open
szybnev wants to merge 6 commits into
Open
fix: Connection closed на каждом HTTP-вызове + улучшения логгинга (v0.6.1)#1szybnev wants to merge 6 commits into
szybnev wants to merge 6 commits into
Conversation
added 6 commits
April 19, 2026 13:28
structlog использовал PrintLogger(file=sys.stdout) по умолчанию, при этом
logging.basicConfig(stream=sys.stderr) настраивал только stdlib-логгер. На
каждом реальном API-вызове log.info("ozon_request", ...) в transport/base.py
ломал MCP JSON-RPC фрейм на stdout — клиент получал "Connection closed".
Статические инструменты (ozon_list_sections и т.п.) работали, т.к. не
логируют — это и маскировало баг.
Чиню:
- PrintLoggerFactory(file=sys.stderr) для structlog.
- JSONRenderer(ensure_ascii=False) — Cyrillic в логах читается.
- sys.stderr.reconfigure(encoding="utf-8") на Windows, иначе cp1252
роняет handler при Cyrillic в сообщении/traceback.
- asyncio.set_exception_handler — unawaited task exceptions теперь
проходят через structlog событием asyncio_task_crashed.
- Опциональный RotatingFileHandler через OZON_LOG_FILE — stderr MCP
клиентов часто не видно оператору, файл-лог выручает.
…чениях До правки любое необработанное исключение в tool-функции уходило наверх через FastMCP и ломало stdio-трубу — клиент видел Connection closed, без стектрейса. @safe_tool оборачивает handler в try/except BaseException, конвертит всё в структурный error envelope (error_type="internal"), сохраняет traceback в лог. KeyboardInterrupt/SystemExit по-прежнему пропускает наверх. - Новый модуль tools/_safety.py с декоратором @safe_tool (sync + async). - Применён к ozon_call_method, ozon_fetch_all, ozon_get_subscription_status, ozon_list_methods_for_subscription. - В schema/errors.py добавлен новый error_type "internal" — клиенты могут читать payload.exception_class для диагностики. - tool_entry / tool_exit логируются на DEBUG с duration_ms.
…sp + latency
Три правки в BaseClient.request:
1. _auth_headers() и _rate_limits.for_call() перенесены внутрь try-блока.
Раньше любое их исключение (например, OAuth-refresh Performance-клиента
или битый rate-limit YAML) обходило error-envelope и убивало процесс.
2. Добавлен финальный except Exception → OzonError("unexpected transport
error: ...") для httpx-исключений, которые НЕ наследуются от HTTPError
(SSLError — да, наследуется, но ProxyError и LocalProtocolError в
зависимости от версии httpx могут всплыть иначе) и вообще любых
чужих исключений в стеке.
3. Добавлены DEBUG-логи ozon_request_body и ozon_response_body с
truncate до 1 KB, плюс INFO-событие ozon_response с duration_ms
и status_code. Помогает отладить интеграцию не включая httpcore
wire-level log.
MCP stdio глотает исключения — клиент видит только Connection closed без стектрейса. Чтобы не гадать, добавлены два пути отладки, которые обходят stdio и печатают полный traceback в stderr: - ozon-mcp --diagnose: делает один вызов POST /v1/seller/info с текущими credentials, печатает OK subscription=... или FAIL + traceback, выходит. Рекомендованный способ проверить связь без запуска MCP-клиента. - scripts/diagnose_live_call.py: тот же probe на уровне транспорта — для отладки в IDE с breakpoint'ами. - scripts/diagnose_mcp_stdio.py: поднимает настоящий MCP stdio subprocess, шлёт initialize + tools/call, параллельно в stderr стримит серверный лог. Воспроизводит ровно то, что делает Claude Code — нужен для регрессии на протокольном уровне.
- test_tool_safety.py — 8 кейсов: safe_tool возвращает envelope на async/sync исключении и пропускает успех, KeyboardInterrupt/SystemExit пробрасывается, транспорт оборачивает ConnectError, generic Exception и исключение из _auth_headers в OzonError. Отдельный кейс test_configure_logging_does_not_write_to_stdout — закрепляет, что structlog НЕ пишет в stdout (это и был v0.6.0-баг с Connection closed). - test_version_sync.py — добавлен encoding="utf-8" в read_text, иначе тест падает на Windows cp1252 при чтении pyproject.toml с Cyrillic.
Version bump + запись в CHANGELOG. Основное: - fix: structlog → stderr, UTF-8 на Windows, JSONRenderer(ensure_ascii=False) - feat: @safe_tool на все handlers, catch-all в транспорте - feat: DEBUG req/resp, latency, OZON_LOG_FILE, --diagnose - feat: asyncio exception handler
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Проблема
В v0.6.0 любой MCP-инструмент, который реально ходит в Ozon API (
ozon_call_method,ozon_fetch_all,ozon_get_subscription_status), убивает процесс — MCP-клиент получаетMCP error -32000: Connection closedбез stacktrace. Статические инструменты (ozon_list_sections,ozon_describe_method,ozon_search_methods) работают нормально.Корневая причина
structlogв_configure_logging()конфигурировался безlogger_factory, из-за чего использовал дефолтныйPrintLogger(file=sys.stdout).logging.basicConfig(stream=sys.stderr, ...)настраивал только stdlib-логгер — structlog оставался на stdout.На каждом реальном API-вызове
log.info(\"ozon_request\", ...)вtransport/base.py:97выводил JSON-строку на stdout, который MCP stdio использует для JSON-RPC фреймов. Клиент получал невалидный фрейм и закрывал трубу.Статические инструменты не логируют — это и маскировало баг. Startup-логи (
catalog_loaded,knowledge_loadedи т. п.) тоже уходили в stdout, но ДО того, как FastMCP перехватывал stdio, так что не ломали протокол — а при первом же реальном запросе падало.Воспроизведение
Скрипт поднимает настоящий MCP-сервер через stdio и шлёт
initialize+tools/call ozon_get_subscription_status. На 0.6.0 клиент получает в stdout лог-событие{\"event\": \"loading_catalog\", ...}вместо JSON-RPC ответа.Исправление
fix: structlog → stderr (корневой)
PrintLoggerFactory(file=sys.stderr)вstructlog.configure(...).JSONRenderer(ensure_ascii=False)— Cyrillic в логах читается, а не\uXXXX.sys.stderr.reconfigure(encoding=\"utf-8\")на Windows — cp1252 иначе падает на Cyrillic в traceback.feat: defense-in-depth
@safe_tool(tools/_safety.py) оборачивает handlers вtry/except BaseException, конвертит всё в envelope{\"error_type\": \"internal\", ...}, пишет стектрейс в лог на ERROR.KeyboardInterrupt/SystemExitпо-прежнему пробрасывает. Применён кozon_call_method,ozon_fetch_all,ozon_get_subscription_status,ozon_list_methods_for_subscription. Добавлен новыйerror_type=\"internal\"вschema/errors.py.BaseClient.request:_auth_headers()и_rate_limits.for_call()перенесены ВНУТРЬtry-блока (раньше любая их ошибка обходила envelope). Добавлен финальныйexcept Exception → OzonError(\"unexpected transport error: ...\")— страховка на httpx-исключения, не являющиесяHTTPError-подклассами, и на чужие middleware.asyncio_task_crashed.feat: логгинг
ozon_request_body/ozon_response_bodyс truncate до 1 KB.ozon_responseсstatus_codeиduration_ms.tool_entry/tool_exitна DEBUG сduration_ms(через@safe_tool).OZON_LOG_FILE— добавляетRotatingFileHandler(5 MB × 3 бэкапа) параллельно stderr. stderr MCP-клиента часто не видно пользователю — файл-лог выручает.ozon-mcp --diagnose— новый CLI-флаг. Делает один вызов/v1/seller/info, печатаетOK subscription=...илиFAIL+ traceback, выходит. Рекомендованный способ проверить связь без MCP-клиента.scripts/ (отладочные, не часть пакета)
scripts/diagnose_live_call.py— out-of-band probe транспорта.scripts/diagnose_mcp_stdio.py— полный прогон MCP stdio subprocess'ом, как делает Claude Code. Нужен для регрессии на протокольном уровне.Тесты
Новый файл
tests/unit/test_tool_safety.py(8 кейсов):safe_toolвозвращает envelope на sync/async исключении, пропускает успех, пробрасываетKeyboardInterrupt.ConnectError, genericException, исключение из_auth_headersвOzonError.test_configure_logging_does_not_write_to_stdout— structlog НЕ пишет в stdout, иначе протокол ломается.Также:
tests/unit/test_version_sync.py— добавленencoding=\"utf-8\"вread_text(падало на Windows cp1252 при чтенииpyproject.tomlс Cyrillic — pre-existing, не мой баг, но на той же теме).Результаты: 244 теста зелёные (
tests/unitбезtest_subscription_generator.py— pre-existing Windows-баг генератора, не связан),mypy --strictчисто,ruffчисто.Верификация end-to-end
Запущен
scripts/diagnose_mcp_stdio.pyс реальными credentials — получены корректные JSON-RPC ответы отozon_get_subscription_statusиozon_call_method(\"ProductAPI_GetProductList\", limit=10). Сервер остался жив между запросами. Логи разделились чисто: JSON-RPC в stdout, structlog-события в stderr, видныduration_ms,tool_entry/tool_exit.Changelog
Версия поднята до
0.6.1, запись вCHANGELOG.mdс подробным описанием. Изменения не ломают обратную совместимость: envelopeerror_type=\"internal\"— новый вариант, старые клиенты игнорируют.Test plan
cd ozon-mcp && uv run pytest tests/unit --ignore=tests/unit/test_subscription_generator.py -vuv run mypy src/ && uv run ruff check src/OZON_CLIENT_ID=... OZON_API_KEY=... uv run ozon-mcp --diagnose— должен напечататьOK subscription=....ozon_get_subscription_status— получить JSON-ответ, а неConnection closed.