Однажды в логах сборщика данных появилась строка «не удалось получить данные:» — и дальше пустота. Разработчик годами считал это багом форматирования, пока не выяснил: код написан правильно, просто у asyncio.TimeoutError пустой str(). Эта история — отличный пример того, как пустое сообщение об ошибке может запустить цепочку неверных решений, включая архитектурные. Разберём, что произошло, и как избежать подобных ловушек.

Ситуация усугубилась тем, что рядом лежал хелпер с тремя попытками и паузой в пять секунд, написанный специально для сетевых сбоев. Но ловил он except aiohttp.ClientError. Начиная с Python 3.11, asyncio.TimeoutError — это встроенный TimeoutError, наследник OSError, и к иерархии aiohttp он отношения не имеет. То есть единственная ошибка, ради которой ретраи существовали, пролетала мимо них. При этом у aiohttp есть свои таймаутные исключения, которые как раз ClientError, — и это сбивает с толку.

Почему сообщение об ошибке оказалось пустым

Многие исключения в Python имеют осмысленное строковое представление, но asyncio.TimeoutError (а с 3.11 — TimeoutError) не передаёт в конструктор никаких аргументов. Поэтому str(e) возвращает пустую строку. В логе появляется «не удалось получить данные:» и ничего после двоеточия. Это не баг форматирования, а особенность исключения. Если вы логируете только str(e), вы теряете информацию о типе ошибки и контексте.

Решение простое: логируйте repr(e) или явно указывайте тип исключения. Например, logger.error('Ошибка: %r', e) даст TimeoutError(), что сразу прояснит ситуацию. Также полезно добавлять контекст: какой URL запрашивался, сколько времени ждали, какие настройки таймаута.

Как неправильный перехват исключений ломает ретраи

Хелпер с тремя попытками был написан для обработки сетевых сбоев, но ловил только aiohttp.ClientError. В то же время asyncio.TimeoutError (он же TimeoutError) не является наследником ClientError. Поэтому таймауты не попадали в обработчик и не вызывали повторных попыток. Разработчик был уверен, что ретраи работают, но на самом деле они не срабатывали для самой частой проблемы — таймаутов.

  • Типичная ошибка: перехватывать слишком узкий класс исключений, не учитывая встроенные таймауты.
  • Как исправить: явно добавлять except (aiohttp.ClientError, asyncio.TimeoutError) или использовать более широкий except (aiohttp.ClientError, OSError), так как TimeoutError наследуется от OSError.
  • Проверка: напишите тест, который имитирует таймаут, и убедитесь, что ретраи срабатывают.

Кроме того, стоит помнить, что у aiohttp есть собственные исключения таймаутов, например aiohttp.ServerTimeoutError, которые являются подклассами ClientError. Их нужно обрабатывать отдельно или включать в общий перехват.

Как пустая ошибка привела к ложному архитектурному выводу

Из-за отсутствия информации в логах разработчик предположил, что проблема в сетевых ограничениях VPS. Он записал в заметки «сетевое ограничение VPS» и построил вокруг этого архитектурное решение — например, добавил дополнительные прокси или изменил схему развёртывания. На самом деле никаких сетевых ограничений не было, а ошибка была вызвана таймаутом, который не обрабатывался ретраями. Это классический пример того, как недостаток диагностики приводит к дорогостоящим и ненужным изменениям.

Чтобы избежать такого:

  • Всегда логируйте тип исключения и repr(e), а не только str(e).
  • Добавляйте в лог контекст: URL, параметры запроса, время ожидания.
  • Проверяйте гипотезы экспериментом, а не стройте архитектуру на догадках.
  • Используйте мониторинг и алерты по количеству ошибок, чтобы вовремя замечать аномалии.

Практические шаги для надёжной обработки ошибок

Вот конкретные действия, которые помогут избежать подобных ситуаций в вашем проекте:

  • Изучите иерархию исключений используемых библиотек. Для asyncio и aiohttp составьте список таймаутных исключений и их базовых классов.
  • Настройте логирование так, чтобы в сообщении всегда был тип ошибки. Например, используйте формат %(exc_info)s или repr(e).
  • Пишите тесты на обработку ошибок: имитируйте таймауты, сетевые сбои и проверяйте, что ретраи срабатывают.
  • Пересмотрите существующие обработчики: добавьте недостающие классы исключений, особенно asyncio.TimeoutError и OSError.
  • Документируйте предположения: если вы делаете архитектурный вывод на основе логов, убедитесь, что он подтверждён экспериментально.

История с пустой ошибкой — напоминание о том, что дьявол в деталях. Пустое сообщение может скрывать за собой важную информацию, а неверная интерпретация — приводить к лишним затратам. Внимательность к типам исключений и контексту логирования помогает принимать верные решения и не строить воздушные замки.