В логе было «не удалось получить данные:» и дальше пустота. Это и была вся ошибка
У меня в карте парковок есть сборщик, который раз в полчаса ходит за данными по нескольким городам. Один из источников австралийский, портал открытых данных Мельбурна. Иногда он не отвечает, и тогда в логе появляется такая строка:
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.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.