The Daily Newsstand · Free, Always
Friday, October 30, 2026

В логе было «не удалось получить данные:» и дальше пустота. Это и была вся ошибка

Translate

У меня в карте парковок есть сборщик, который раз в полчаса ходит за данными по нескольким городам. Один из источников австралийский, портал открытых данных Мельбурна. Иногда он не отвечает, и тогда в логе появляется такая строка:

2026-10-06 21:25:52 - parking-service - ERROR - Мельбурн (data.melbourne.vic.gov.au): не удалось получить данные:

Двоеточие, и за ним ничего.

Я довольно долго считал это багом форматирования: мол, где-то потерял аргумент в %s. Полез смотреть и обнаружил, что код написан правильно.

try:
    snapshots = await source.fetch()
except Exception as error:
    logger.error('%s: не удалось получить данные: %s', source.title, error)
    logger.error('Traceback: %s', traceback.format_exc())
    return

Аргумент на месте, исключение подставляется. Просто у этого исключения нет текста.

>>> str(asyncio.TimeoutError())
''

Пустая строка. Логгер честно напечатал всё, что у него было.

Почему именно таймаут

Это я узнал из соседней строки, которую раньше не читал до конца. Трейсбек пишется вторым сообщением, и он показывает, где именно всё встало:

File "aiohttp/client.py", line 770, in _request
File "aiohttp/connector.py", line 642, in connect
File "aiohttp/connector.py", line 1209, in _create_connection
File "aiohttp/connector.py", line 1268, in _wrap_create_connection
    sock = await aiohappyeyeballs.start_connection(
File "aiohappyeyeballs/impl.py", line 89, in start_connection
    sock, _, _ = await _staggered.staggered_race(

Это установка соединения. Не чтение тела, не разбор JSON. Процесс не дождался даже того, чтобы с сервером начать разговаривать.

Деталь важная, и дальше станет понятно почему.

Ретраи, которые не сработали ни разу

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

DEFAULT_TIMEOUT = 60
DEFAULT_MAX_RETRIES = 3
DEFAULT_RETRY_DELAY = 5

for attempt in range(1, max_retries + 1):
    try:
        client_timeout = aiohttp.ClientTimeout(total=timeout)
        async with aiohttp.ClientSession(timeout=client_timeout) as session:
            async with session.get(url, params=params) as response:
                ...
    except aiohttp.ClientError as error:
        last_error = f'ошибка соединения: {error}'
        logger.error(...)

    if attempt < max_retries:
        await asyncio.sleep(retry_delay)

Три попытки, пять секунд между ними. Написано ровно для случая «источник моргнул».

Теперь посмотрим, что ловит except aiohttp.ClientError, на той самой версии, которая крутится в проде:

python 3.12.3  aiohttp 3.12.14

asyncio.TimeoutError is TimeoutError: True
MRO: ['TimeoutError', 'OSError', 'Exception', 'BaseException', 'object']
ловится ли как aiohttp.ClientError: False

asyncio.TimeoutError начиная с Python 3.11 это встроенный TimeoutError, и наследуется он от OSError. К иерархии aiohttp он отношения не имеет.

То есть единственная ошибка, ради которой писались ретраи, в этот except не попадает. Она пролетает мимо цикла целиком, выходит из функции на первой же попытке, и max_retries = 3 не применяется никогда. Пауза в пять секунд тоже не наступает, потому что до неё не доходит.

Почему это так трудно заметить

Вот место, из-за которого я и решил это описать.

У aiohttp есть собственные исключения про таймауты, и они как раз наследники ClientError:

ServerTimeoutError      -> ClientError: True
ConnectionTimeoutError  -> ClientError: True
SocketTimeoutError      -> ClientError: True

Если открыть документацию и увидеть эти три класса, вывод напрашивается сам: таймауты у aiohttp это ClientError, значит мой except их накрывает.

И это правда для них. Но не для того таймаута, который я настроил. ClientTimeout(total=60) ограничивает операцию целиком, и при срабатывании поднимает голый asyncio.TimeoutError, а не что-то из списка выше.

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

Чем я это объяснял раньше

Теперь неприятная часть.

Когда я подключал Мельбурн, большие запросы к порталу у меня действительно не проходили. Я записал это в заметки по проекту так:

Найдено сетевое ограничение VPS: к data.melbourne.vic.gov.au ответы больше ~15 КБ стабильно обрываются на полпути (замерено: 7,8 КБ приходит за 1,2 с, 59 КБ висит до таймаута, 1,2 МБ тоже).

И построил вокруг этого решение: вместо штатной выгрузки /exports/json адаптер ходит страницами по сто записей, а агрегацию занятости просит сделать сам портал.

Сел писать про это статью и пошёл за доказательством. С того же сервера, тем же curl:

/exports/json        байт=1508318  время=5,78 с  код=200
exports?limit=1000   байт=238204   время=3,27 с  код=200
зоны, exports        байт=126228   время=2,09 с  код=200

Полтора мегабайта за шесть секунд. Ровно тот объём, который записан у меня как «висит до таймаута».

Потолка в 15 КБ нет. Проверил и другие объяснения, которые напрашивались. IPv6 отпадает: у домена только A-записи. MTU отпадает: на машине есть туннели с MTU 1420 и 1376, но ip route get показывает, что до Мельбурна трафик идёт напрямую через основной интерфейс, а туннели везут только свои подсети.

Что было на самом деле, я восстановить не могу, снимков того дня у меня нет. Самое правдоподобное: связь до Австралии иногда отваливается на этапе установки соединения, я несколько раз подряд попал в это окно на больших запросах, а размер запомнился как причина, потому что большие запросы я и пробовал. Ретраи, которые должны были сгладить такое, не сработали по причине выше, и каждая попытка выглядела как окончательный отказ.

Ошибки в логе мне бы тогда не помогли. Они были пустыми.

Что с этим делать

Правка на две строки:

except (aiohttp.ClientError, asyncio.TimeoutError) as error:
    last_error = f'ошибка соединения: {type(error).__name__}: {error}'

Первая возвращает таймауту ретраи. Вторая добавляет имя класса, чтобы исключение без текста перестало выглядеть как пустое место: вместо «не удалось получить данные: » в логе будет «TimeoutError».

Пагинацию я оставлю. Она оказалась правильной не по той причине, по которой я её делал: девять страниц вместо одной большой выгрузки это девять коротких соединений вместо одного долгого, и агрегацию за меня считает портал. Но писал я её, чтобы обойти ограничение, которого нет.

Из всего этого я забираю две вещи.

Исключение без текста это не отсутствие информации, а её разновидность. str() пустой у считанных классов, и TimeoutError среди них. Если в логе осталось двоеточие и пустота, список кандидатов короткий. Проще всего не доводить до этого и печатать type(error).__name__ всегда, потому что имя класса есть у любого исключения, а сообщение не у любого.

И второе. Устойчивость кода определяется не числом попыток в конфиге, а тем, какие классы перечислены в except. У меня стояло три попытки, и ровно ноль из них случилось в том единственном сценарии, ради которого они писались. Проверяется это не чтением документации, а одной строкой в консоли: issubclass(тот_класс, то_что_я_ловлю).

Данные, которые всё это собирает, лежат открыто: 4,6 млн получасовых замеров загруженности парковок, CC BY 4.0.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.