Troubleshooting — REST API & Data Formats для Junior Data Engineer
База знаний типичных ошибок курса REST API & Data Formats для Junior Data Engineer.
Категория
Симптомы
- При запросе через requests/httpx: `requests.exceptions.SSLError: HTTPSConnectionPool(host='internal.corp', port=443): Max retries exceeded with url: /api ... [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1006)`. Возможны также варианты: 'self signed certificate', 'self signed certificate in certificate chain', 'certificate has expired'.
Причина
Сервер использует self-signed сертификат (типично для internal/corporate API) Сервер подписан корпоративной CA, которой нет в системном trust store Сертификат сервера просрочен (`certificate has expired`) -- проблема на сервере, чинить там На macOS Python из python.org не использует системные certs -- нужно запустить `Install Certificates.command` Устаревший `certifi` пакет в venv -- отсутствуют новые корневые CA Корпоративный MITM-прокси перехватывает TLS и подменяет cert на свой (Zscaler, Palo Alto)
Решение
- Получить корпоративный CA сертификат и указать его:
requests.get(url, verify='/path/to/corp-ca.pem')или env-varREQUESTS_CA_BUNDLE=/path/to/corp-ca.pem - Для системных certs обновить certifi:
pip install -U certifi - На macOS python.org-installer: запустить
/Applications/Python\ 3.x/Install\ Certificates.command - Использовать системный truststore (Python 3.10+):
pip install truststoreиtruststore.inject_into_ssl()-- тянет certs из ОС включая корпоративные CA - Только для локального дебага (НИКОГДА в prod):
requests.get(url, verify=False)плюсurllib3.disable_warnings()-- но это убирает защиту от MITM - Если сервер с самоподписью -- конвертировать его cert в PEM и указать через verify=path
Симптомы
- `ssl.CertificateError: hostname 'api.example.com' doesn't match either of '*.example.org', 'example.org'` или `urllib3.exceptions.SSLError: hostname 'api.example.com' doesn't match 'foo.bar.com'`.
Причина
DNS указывает на сервер, у которого cert на другой домен (типично при ошибочной CNAME или старом DNS) Запрос идёт по IP, а cert выдан на DNS-имя Wildcard cert (`*.example.com`) не покрывает несколько уровней (`api.v2.example.com`) У сервера cert на старое имя, после переименования domain не обновили Корпоративный MITM-прокси не успел проинжектить cert для нового домена
Решение
- Если используете IP вместо hostname -- переключиться на hostname или добавить запись в
/etc/hosts - Если cert старый -- попросить ops/devops обновить сертификат с правильными SAN
- Если перенаправление через proxy/CDN -- проверить настройку SNI на промежуточном узле
- Никогда в prod: отключить hostname check (urllib3 InsecureRequestWarning); только для дебага конкретной machine
- Через requests Adapter подменить ServerHostnameContext, если очень нужно (редкий случай -- обычно фиксить cert)
Симптомы
- `ssl.SSLError: [SSL: TLSV1_ALERT_PROTOCOL_VERSION] tlsv1 alert protocol version (_ssl.c:1006)` или `ssl.SSLError: [SSL: WRONG_VERSION_NUMBER]` или `urllib3 ... EOF occurred in violation of protocol`. Часто на старых внутренних серверах с TLS 1.0/1.1.
Причина
Сервер настроен только на TLS 1.0/1.1, ваш клиент (Python 3.10+ с OpenSSL 3.x) их отключил по умолчанию Сервер требует TLS 1.3, у вас старый OpenSSL без поддержки Cipher suite mismatch -- у сервера и клиента нет общих cipher'ов Между клиентом и сервером MITM proxy в инспектирующем режиме режет соединение На стороне сервера протух SSL-context (нужен перезапуск)
Решение
- Если сервер на TLS 1.0/1.1 -- попросить admin обновить (deprecated с 2020); как временный workaround в Python создать
SSLContextсminimum_version=TLSVersion.TLSv1через custom HTTPAdapter - Если у клиента старый OpenSSL -- обновить Python или OpenSSL (
brew upgrade openssl, на Linuxapt installсвежую версию) - Если cipher suite mismatch -- явно указать допустимые cipher'ы в SSLContext.set_ciphers()
- Через requests-adapter с custom ssl_context можно зафиксировать TLS-версию и cipher'ы
- Если проблема с MITM proxy -- попросить infosec временно вынести API из inspection
Симптомы
- `415 Unsupported Media Type` от сервера, или Flask/Django ожидает JSON, а получает None / пустой словарь. FastAPI/Pydantic выдаёт `422 Unprocessable Entity` с `"detail": [{"loc": ["body"], "msg": "field required"}]`.
Причина
Используете `requests.post(url, data=json.dumps(payload))` -- Content-Type не выставляется, дефолтится в `application/x-www-form-urlencoded` Правильно: `requests.post(url, json=payload)` -- requests сам поставит `Content-Type: application/json` Передаёте bytes/str через `data=` -- Content-Type вообще не проставлен У httpx тот же эффект: `data=` это form, для JSON `json=` Сервер требует особенный MIME (`application/vnd.api+json`, `application/hal+json`), а вы шлёте обычный application/json
Решение
- Использовать
json=параметр в requests/httpx -- он сериализует и проставит Content-Type автоматически - Если шлёте уже сериализованный JSON через
data=-- обязательно явный header:headers={'Content-Type': 'application/json'} - Для vendor MIME:
headers={'Content-Type': 'application/vnd.api+json'}плюсdata=json.dumps(payload) - Проверить, что отдаёт сервер по умолчанию: возможно требует
Accept: application/jsonтоже - В FastAPI/Pydantic ошибка 422 содержит локацию поля -- читать
response.json()['detail']и чинить структуру body
Симптомы
- Запрос к `https://api.example.com/users/42` редиректится 301/302 на новый URL, второй запрос приходит без `Authorization` header -- сервер возвращает 401. В DevTools/Wireshark видно: первый запрос с header, второй -- без.
Причина
requests/httpx по умолчанию НЕ передают auth-headers при cross-host redirect (защита от утечки токена на другой хост) Редирект ведёт на другой subdomain (`api.example.com -> www.example.com`) -- для библиотеки это разные hosts Редирект с https на http (схема изменилась) -- Authorization срезается Сервер при 30x делает Location с relative URL, библиотека правильно резолвит и видит 'другой хост'
Решение
- Сначала найти canonical URL без редиректа:
r = requests.head(url, allow_redirects=True); canonical = r.url-- обращаться сразу туда - Реализовать собственный hook на redirect: subclass от
requests.Session.rebuild_auth, который не срезает header при доверенных хостах - Использовать
allow_redirects=Falseи обрабатывать редиректы вручную, прокидывая Authorization осознанно - Попросить API не делать cross-host redirects для авторизованных endpoints -- это плохая практика на их стороне
- Никогда не пробрасывайте Authorization на всё подряд -- это потенциальная утечка токена; явно whitelist'ьте доверенные домены
Симптомы
- В DevTools Console: `Access to fetch at 'https://api.example.com/v1/users' from origin 'https://app.example.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.` В Network tab видно OPTIONS-запрос со статусом 200/204, но без нужных CORS-headers, а сам PUT/POST даже не отправлен.
Причина
Сервер не настроил CORS -- нет `Access-Control-Allow-Origin` в ответе на OPTIONS ACAO есть, но не включает ваш origin (точное совпадение или `*`) Запрос использует Authorization header, но в `Access-Control-Allow-Headers` сервер его не разрешил Метод PUT/PATCH/DELETE не входит в `Access-Control-Allow-Methods` `Access-Control-Allow-Credentials: true` нужен, но не выставлен -- при `credentials: 'include'` cookies/auth не уходят Wildcard `Access-Control-Allow-Origin: *` несовместим с credentials -- нужен конкретный origin
Решение
- В FastAPI: добавить CORSMiddleware с правильным
allow_origins,allow_methods,allow_headers,allow_credentials - В Express:
app.use(cors({origin: 'https://app.example.com', credentials: true})) - В nginx как proxy перед API: добавить
add_header Access-Control-Allow-Origin ...для OPTIONS и для основного запроса - Помнить:
Access-Control-Allow-Origin: *НЕ работает вместе сAccess-Control-Allow-Credentials: true-- указывать точный origin - Если запрос ОТ Python (а не браузера) -- CORS неактуален, ошибка не воспроизводится, в curl всё работает; не путать с server-side rejection
- Для dev -- настроить vite/webpack proxy на API, чтобы фронт и API были на одном origin
Симптомы
- Отправляете `Authorization: Bearer <token>`, токен валидный (только что получен), сервер всё равно отвечает 401. В body: `{"error":"invalid_token"}` или `{"error":"token expired"}` или вообще без деталей.
Причина
Токен на самом деле истёк (`exp` claim в прошлом) -- проверить `jwt.io` или `jwt.decode(token, verify=False)` Перепутали token type: используете id_token вместо access_token (OIDC даёт оба) Сервер требует `Bearer <token>` ровно -- а вы шлёте `bearer <token>` (lowercase) или просто `<token>` без префикса Токен для другого audience (`aud` claim) -- сервер не принимает чужие токены Токен для другой среды: prod-token шлёте на staging-API или наоборот Между issuer и API нарушена синхронизация ключей (rotation rsa-keys) -- старые JWT не верифицируются Clock skew: JWT выдан с `iat` в будущем (часы клиента ушли)
Решение
- Проверить
expclaim -- если прошёл, запросить новый токен через refresh - Удостовериться в формате header:
Authorization: Bearer <token>точно так, с пробелом и capital 'B' - Сверить audience и issuer с тем, что ожидает API -- попросить документацию
- Проверить env: prod-токен только для prod, staging -- для staging
- Если clock skew: настроить NTP на хосте (
timedatectl statusна linux,sntpна mac) - Если ключи issuer rotated -- пересоздать токен (старый стал invalid)
Симптомы
- Получаете 403 Forbidden, думаете что нужно перелогиниться, но новый токен тоже даёт 403. Или наоборот: 401 трактуете как 'нет прав', хотя достаточно обновить токен.
Причина
Не понимаете различие: 401 = 'authentication failed' (нет/невалидный credentials), 403 = 'authentication ok, но не хватает permissions' API нестандартно использует коды: 403 при истёкшем токене (должен 401), или 401 при отсутствии scope (должен 403) Некоторые API намеренно возвращают 404 вместо 403 чтобы не раскрывать существование ресурса (GitHub так делает для private repos) Прокси/WAF возвращает 403 от себя (заблокированный IP, geo-restriction) -- не сам API
Решение
- 401: запросить новый токен, проверить format header, sync clock
- 403: токен валиден, но не хватает scope/role -- запросить токен с правильным scope, или попросить admin дать роль
- При 403 от WAF/прокси (по
Server: cloudflareв headers): это не от API, проверить IP whitelist, geo-restriction - При 403 на собственный ресурс (
/me) -- баг в API, в норме не должно быть - Всегда читать
WWW-Authenticateheader в 401 -- там может быть error_description
Симптомы
- Запрос к FastAPI возвращает 422 с body вида: `{"detail":[{"type":"missing","loc":["body","email"],"msg":"Field required","input":{...}}]}` или `{"detail":[{"type":"int_parsing","loc":["path","id"],"msg":"Input should be a valid integer"}]}`.
Причина
Body не соответствует Pydantic-модели: пропущено required-поле, неверный тип, лишние поля при `model_config = ConfigDict(extra='forbid')` Path-параметр не парсится в нужный тип (`/users/abc` где ожидается int) Query-параметр обязательный, не передан JSON неправильно сформирован (`single quotes` вместо `"`, trailing comma) Не послан Content-Type -- FastAPI пытается прочитать как форму вместо JSON
Решение
- Читать
detail[].locиdetail[].msg-- точные локация и причина - Проверить, что Content-Type: application/json (см.
content-type-not-set) - Сверить отправляемые поля с
/openapi.json(#/components/schemas/<Model>) - Для path-int -- передавать число, не строку:
/users/42не/users/abc - Если model отвергает extra-fields (
extra='forbid') -- убрать лишнее или попросить разработчиков ослабить ограничение - Использовать openapi-python-client для типизированного клиента -- компилятор поймает mismatch до runtime
Симптомы
- Сервер отдаёт 429, в body может быть `{"error":"rate_limit_exceeded"}`, в headers НЕТ `Retry-After`. Ваш retry-loop через 1 секунду снова попадает на 429, заходит в бесконечный retry с тем же результатом.
Причина
API не реализует Retry-After (плохая практика, но встречается часто) Retry-After есть, но вы его не читаете в своём retry-handler Применяете один retry interval (`sleep(1)`), а реальный лимит -- например, 100 req/min, нужно ждать 60 сек Лимит по другому окну: per-second, per-minute, per-hour, per-day -- без Retry-After непонятно которое Лимит per-key, а у вас несколько процессов с тем же токеном -- каждый retry'ит, общий QPS выше квоты
Решение
- Использовать exponential backoff с jitter, если Retry-After нет:
sleep = random.uniform(0, 2**attempt) - Если Retry-After есть -- респектить ровно его
- Прочитать
X-RateLimit-Limit/X-RateLimit-Remaining/X-RateLimit-Resetheaders (где-то они называютсяRateLimit-*без X-) - Поддерживать client-side rate limiter (
aiolimiterдля asyncio,pyrate-limiterдля sync) -- не upирать в server-side лимит - Если несколько процессов с тем же ключом -- координировать через Redis (
redis-py+ token bucket) или distributed rate limiter - Запросить у API более высокий лимит для prod (часто доступно за плату или по заявке)
Симптомы
- `json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)` при `r.json()`. Status code часто 200/204/304, body действительно пустой или содержит whitespace.
Причина
Сервер вернул 204 No Content -- нет body по спецификации 304 Not Modified в conditional GET -- тоже без body Сервер вернул HTML error-page (5xx, 502 от nginx) с Content-Type: text/html -- это не JSON API вернул пустую строку как 'успех' -- некрасиво, но встречается Streaming response, который ещё не дочитан до конца Сервер закрыл соединение раньше времени, body обрезан (ChunkedEncodingError выше уровнем)
Решение
- Перед
r.json()проверятьr.status_code(204/304 не должны парситься как JSON) - Проверять
r.headers.get('Content-Type', '').startswith('application/json') - Обернуть в try/except json.JSONDecodeError, логировать
r.text[:500]для дебага - Если сервер возвращает HTML на ошибки --
r.raise_for_status()сначала, потомr.json() - Использовать
r.json() if r.content else Noneкак защиту - Если streaming -- не вызывать json() до полного прочтения
Симптомы
- `json.dumps({'value': float('nan')})` -> `'{"value": NaN}'`. Сервер с строгим парсером (Go encoding/json, Postgres jsonb) ругается `invalid character 'N' looking for beginning of value`. Или наоборот -- приходит JSON с `NaN`/`Infinity`, ваш строгий парсер падает.
Причина
Python `json.dumps` по умолчанию допускает NaN/Infinity (`allow_nan=True`), хотя это extension, не RFC 8259 pandas `to_json()` пишет NaN как `null` (по умолчанию), но может писать как `NaN` с `default_handler` Go, Rust, Java по умолчанию не парсят NaN -- strict RFC 8259 База данных принимает невалидный JSON в text-столбец, но при парсинге в код падает В DE-пайплайне `pandas.read_csv` с пустыми ячейками создаёт NaN, потом `df.to_json()` или `df.to_dict()` ведёт к проблеме
Решение
- Перед сериализацией заменять NaN/Inf на None:
df.replace([np.inf, -np.inf, np.nan], None) - Использовать
json.dumps(data, allow_nan=False)-- ловить ошибку раньше - Для pandas:
df.to_json(orient='records')-- NaN -> null, илиdf.fillna(value) - Кастомный encoder:
json.dumps(data, default=lambda o: None if isinstance(o, float) and (math.isnan(o) or math.isinf(o)) else str(o)) - На стороне receiver -- orjson с
OPT_NAIVE_UTCили явная проверка структуры; orjson по умолчанию страйкт
Симптомы
- `TypeError: Object of type datetime is not JSON serializable` или `TypeError: Object of type Decimal is not JSON serializable` при `json.dumps()`. Также с UUID, date, time, set, bytes.
Причина
Стандартный `json` не знает, как сериализовать datetime/Decimal/UUID/bytes -- нужно передать `default=` функцию Получили из SQL-запроса (`psycopg2` возвращает Decimal для numeric, datetime для timestamp) Pydantic в модели объявил datetime, но при `model.dict()` это всё ещё datetime -- нужен `model.json()` или `model.model_dump(mode='json')`
Решение
- Передавать
default=strдля quick-fix:json.dumps(data, default=str)-- но теряется тип (всё в строку) - Кастомный encoder:
def default(o): if isinstance(o, datetime): return o.isoformat(); ...; raise TypeError - Использовать
orjson-- нативно поддерживает datetime, UUID, dataclasses:orjson.dumps(data) - Для Pydantic v2:
model.model_dump(mode='json')илиmodel.model_dump_json() - Для Decimal -- конвертировать в str (сохраняет precision) или float (теряется):
json.dumps(d, default=lambda o: str(o) if isinstance(o, Decimal) else None) - FastAPI делает это автоматически в response -- ставит datetime в ISO 8601, Decimal в string
Симптомы
- Открываете `df = pd.read_csv('data.csv')` -- первая колонка называется `\ufeffid` вместо `id`. Или при чтении csv.DictReader первый header содержит невидимый BOM, доступ `row['id']` падает с KeyError, а `row['\ufeffid']` работает.
Причина
Файл создан Excel или другим Windows-tool с BOM (Byte Order Mark, `EF BB BF` в hex) Файл получен с сервера, который пишет BOM как 'identifier' для encoding detection Открыли с `encoding='utf-8'` -- он BOM сохраняет, надо `encoding='utf-8-sig'` Конвертация между UTF-16 и UTF-8 оставила BOM Скрипт сам пишет с BOM через `with open(... encoding='utf-8-sig', mode='w')`
Решение
- Читать с
encoding='utf-8-sig'-- Python автоматически вырежет BOM - В pandas:
pd.read_csv('data.csv', encoding='utf-8-sig') - Если уже прочитано:
df.columns = df.columns.str.replace('\ufeff', '') - При записи --
open('out.csv', 'w', encoding='utf-8')(без -sig) -- БЕЗ BOM - В preprocessing pipeline:
sed -i '1s/^\xEF\xBB\xBF//' data.csv(Linux) удалит BOM - Запретить writing BOM в команде -- это давно anti-pattern, Excel ставит, но новые tools уже не должны
Симптомы
- Открыли CSV от 1С/госорганов/legacy-tool в pandas -- на месте русских букв `\xed\xe5\xf2 \xf2\xe0\xea\xee\xe3\xee` или `нет такого`. Или Python падает с `UnicodeDecodeError: 'utf-8' codec can't decode byte 0xed in position 142`.
Причина
Файл сохранён в Windows-1251 (cp1251) -- стандарт для legacy Windows-приложений в кириллице Иногда cp866 (DOS-кодировка), KOI8-R (UNIX исторически) По умолчанию pandas/Python пробует UTF-8 и падает на не-ASCII байтах Скрипт-источник пишет в cp1251 (1С: `windows-1251` default), а consumer ожидает UTF-8
Решение
- Явно указать encoding:
pd.read_csv('data.csv', encoding='cp1251')илиencoding='windows-1251' - Конвертировать файл один раз:
iconv -f cp1251 -t utf-8 data.csv > data_utf8.csv - Если encoding неоднороден --
errors='replace'илиerrors='ignore'(теряются символы) -- в крайнем случае - Использовать
chardet/charset-normalizerдля авто-определения, потом проверить human - Договориться с источником данных слать UTF-8 (если возможно) -- cp1251 уже legacy
- Pandas 2.x:
engine='pyarrow'тоже принимает encoding
Симптомы
- У вас CSV с описаниями товаров, в одном поле -- длинный текст с переносами строк. После чтения количество строк не совпадает с ожидаемым (одна 'логическая' строка превратилась в несколько). Или pandas жалуется `ParserError: Error tokenizing data. C error: Expected 5 fields in line 142, saw 8`.
Причина
Многострочное поле должно быть в кавычках (`"...\n..."`), но кавычки забыли Внутри quoted-поля есть escaped quote (`""`) или `\"` -- разные dialects по-разному обрабатывают Использовали `csv.reader` с `quotechar=None` -- переносы строк интерпретируются как row terminator В pandas: `quoting=csv.QUOTE_NONE` -- кавычки не интерпретируются Файл с CR-only line endings (`\r` без `\n`, ст. Mac до OS X) -- Python считает их line breaks Источник смешал `\n` и `\r\n` в одном файле
Решение
- Правильно: писать многострочные поля в quotes --
csv.writer(f, quoting=csv.QUOTE_ALL) - При чтении:
pd.read_csv('data.csv', quoting=csv.QUOTE_MINIMAL, quotechar='"', escapechar='\\') - Включить
engine='python'в pandas -- медленнее, но строже к dialects - Использовать
csv.Sniffer().sniff(sample)чтобы определить dialect - Если данные сломанные и нельзя пересоздать --
on_bad_lines='skip'(теряет строки) илиon_bad_lines='warn'для дебага - Конвертировать line endings:
dos2unix data.csvилиtr -d '\r' < data.csv
Симптомы
- Любой код, использующий `yaml.load(text)` без указания Loader, уязвим. Атакующий, подсунув YAML с `!!python/object/apply:os.system ["rm -rf /"]`, может выполнить произвольный код. Linter ruff `S506` или bandit `B506` помечает это как high-severity. В production это критическая уязвимость.
Причина
Использование `yaml.load(text)` без `Loader=` параметра -- до PyYAML 5.1 default был FullLoader, после -- warning, но всё ещё работает Доверие к YAML из внешнего источника (config файлы users, API requests) Использование `yaml.unsafe_load()` намеренно (мега-плохая практика) Не знание про `safe_load` и `safe_dump`
Решение
- ВСЕГДА
yaml.safe_load(text)-- поддерживает только базовые типы (dict, list, str, int, float, bool, None) - Для записи --
yaml.safe_dump(data)(не safe_load --dumpобычно ОК, но для симметрии) - Если нужны custom-теги -- использовать
SafeLoaderс явно зарегистрированными constructors - ruamel.yaml -- альтернатива, по умолчанию safer, плюс сохраняет comments и порядок (для редактирования YAML)
- Pre-commit hook bandit/ruff (
B506) -- ловить yaml.load до коммита
Симптомы
- В YAML-конфиге `country: NO` или `is_admin: yes` -- парсер возвращает `False`/`True` вместо строк. Список стран `[NO, YES, GB, US]` -> `[False, True, 'GB', 'US']`. Или поле `version: 1.0` парсится как float `1.0`, не как строка.
Причина
YAML 1.1 (default в PyYAML) трактует `yes`/`no`/`on`/`off`/`y`/`n`/`true`/`false` как booleans (case-insensitive) YAML 1.2 (ruamel.yaml, default в Go yaml.v3) ограничил только `true`/`false` PyYAML до сих пор YAML 1.1 -- известная проблема (см. 'Norway problem') Невнимательная сериализация: `version: 1.0` это float, нужно `version: '1.0'`
Решение
- Всегда заворачивать строки в кавычки в YAML:
country: 'NO',version: '1.0' - Использовать ruamel.yaml с typ='safe' -- YAML 1.2 по умолчанию, нет Norway
- Если контролируете source YAML -- добавить тест, что после parse поля string остаются string
- Объявлять тип в JSON Schema для config-файла -- валидация поймает 'string field is bool'
- Pre-commit для yamllint с правилом
truthy: {check-keys: false}-- предупредит при ambiguous boolean
Симптомы
- У вас Docker Compose с anchor (`&db-config` в одном файле) и хотите использовать в другом (`*db-config`) -- yaml-парсер падает `yaml.YAMLError: found undefined alias 'db-config'`.
Причина
Anchors (&) и aliases (*) валидны ТОЛЬКО внутри одного документа Между файлами anchors не передаются -- yaml-spec не предусматривает include Docker Compose 3.4+ имеет extension fields (`x-*`) и `extends`, но это compose-feature, не yaml Иногда люди ожидают, что `!include other.yaml` сработает -- это не стандарт, нужен custom Loader Multi-doc YAML (`---` separator) -- каждый doc имеет свой scope anchors
Решение
- Держать anchors в одном файле -- если нужно reuse, импортировать содержимое через шаблонизатор
- Использовать Helm/Jinja/envsubst для шаблонизации YAML с includes
- Docker Compose:
extendsполя +x-*extension fields в одном файле - Kubernetes: Kustomize patches и base/overlay structure
- Python: явно загружать оба файла и merge dict --
{**base, **override} - Никогда не пытайтесь сделать
!include other.yamlбез custom Loader -- это unstandard
Симптомы
- `yaml.scanner.ScannerError: while scanning for the next token found character '\t' that cannot start any token` или `yaml.parser.ParserError: mapping values are not allowed here in '<unicode string>', line 5, column 12`.
Причина
В YAML использованы табы вместо пробелов -- yaml-spec разрешает ТОЛЬКО пробелы для отступа Смешаны 2 и 4 пробела на разных уровнях После `:` идёт что-то, что не является valid value (например `key: value: nested`) Editor (VS Code, vim) auto-replace tab -> spaces не настроен для .yaml Скопировали из IDE, которая преобразовала пробелы в табы
Решение
- Заменить табы на пробелы:
expand -t 2 config.yaml > config_fixed.yaml - В vim:
:set expandtab tabstop=2 shiftwidth=2+:retab - В VS Code: settings.json --
"editor.insertSpaces": true,"editor.tabSize": 2, для[yaml] - Pre-commit с yamllint -- ловить до коммита
- .editorconfig в проекте -- единые правила для всех редакторов:
[*.{yaml,yml}]\nindent_style = space\nindent_size = 2
Симптомы
- `requests.exceptions.ConnectionError: HTTPSConnectionPool(host='api.example.com', port=443): Max retries exceeded with url: /v1/users (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x...>: Failed to establish a new connection: [Errno 110] Connection timed out'))`. Что именно случилось -- неясно.
Причина
DNS не резолвится: `Name or service not known` -- проверить hostname или DNS-сервер Хост недоступен: `Connection refused` -- сервер не слушает на порту, или firewall блокирует Connection timed out: маршрут есть, но пакеты не доходят (firewall, network split) TLS handshake failed: см. отдельные пункты по SSL Корпоративный proxy требует auth (407) Нет outbound доступа из контейнера/VM (security group, NAT)
Решение
- Если DNS -- проверить /etc/resolv.conf, использовать публичный DNS (1.1.1.1, 8.8.8.8)
- Если TCP -- проверить firewall (
telnet api.example.com 443илиnc -zv ...), security group, network policy - Если timeout -- проверить routing (
traceroute api.example.com), запросить ops доступ - Если behind proxy -- настроить
HTTPS_PROXY=http://proxy:8080 python script.pyилиrequests.get(url, proxies={'https': 'http://...'}) - В Docker -- проверить, что контейнер имеет outbound (
docker run --rm alpine wget https://example.com) - Поставить timeout=10 -- без него висит вечно, маскирует root cause
Симптомы
- ETL-скрипт зависает на одном из вызовов, не падает с ошибкой, не возвращается. `ps aux | grep python` показывает процесс, в `strace`/`py-spy dump` видно `select` или `read` syscall в socket. На дашборде airflow задача в state `running` уже 6 часов.
Причина
В коде `requests.get(url)` или `requests.post(url, ...)` без `timeout=` параметра -- это бесконечное ожидание по умолчанию У сервера TCP keep-alive, он держит соединение, но не отвечает (slowloris) Сервер шлёт по 1 байту в час -- даже с read-timeout, если timeout per-IO, формально не превышается В httpx `timeout=None` (по умолчанию некоторые конфиги) тоже бесконечный wait
Решение
- ВСЕГДА
requests.get(url, timeout=10)-- total/read timeout 10 сек - Лучше:
timeout=(3.05, 27)--(connect, read) - В httpx:
httpx.Timeout(connect=5, read=10, write=5, pool=2)или простоtimeout=10.0 - Pre-commit hook + ruff
S113(request-without-timeout) -- ловит до коммита - Wrapper-функция, заворачивающая
requests.getс обязательным timeout -- энфорсит на уровне кода - В Airflow задаче -- установить
execution_timeoutна task, как backstop
Симптомы
- `requests.exceptions.TooManyRedirects: Exceeded 30 redirects`. Возможно: `http://...` -> `https://...` -> `http://...` или редирект на самого себя через query param.
Причина
У сервера circular redirect: `/login` -> `/auth` -> `/login` Auth-cookie не передаётся между редиректами (см. `auth-header-lost-on-redirect`), сервер каждый раз редиректит на /login SSL terminator/load balancer редиректит на http, app на https -- bouncing Domain alias: `api.example.com` -> `api2.example.com` -> `api.example.com` Сервер шлёт Location с relative URL, который резолвится в тот же URL
Решение
- Найти root cause через logging redirect-chain -- обычно очевидно, какие два URL bounce
- Если auth-related -- обеспечить cookie/header передачу через
Session(см.auth-header-lost-on-redirect) - Уменьшить
session.max_redirects = 5чтобы быстрее падать вместо долгого hung - Если редирект НЕ нужен --
allow_redirects=False, обрабатывать в коде - Сообщить ops/devs API о circular redirect -- это баг на их стороне
- Проверить, нет ли в URL query-param, который сервер всё время добавляет (
?utm_source=...) -- может цикл из-за этого
Симптомы
- `requests.exceptions.ChunkedEncodingError: ("Connection broken: InvalidChunkLength(got length b'', 0 bytes read)", InvalidChunkLength(got length b'', 0 bytes read))` или `RemoteDisconnected: Remote end closed connection without response`.
Причина
Сервер закрыл соединение посреди отправки chunked-response (timeout на стороне сервера, OOM kill, restart) Прокси/CDN между вами и сервером закрыл idle connection Streaming response (например, JSONL/SSE) -- клиент не успел прочитать, сервер закрыл Сетевой обрыв (Wi-Fi флапнул, mobile network) Сервер вернул `Transfer-Encoding: chunked` но не отправил terminating chunk `0\r\n\r\n`
Решение
- Retry на ChunkedEncodingError -- это transient error
- Использовать
stream=Trueдля больших ответов, обрабатывать chunk-by-chunk - Поднять read_timeout: возможно сервер медленно отвечает
- Если работаете с прокси -- увеличить keep-alive timeout у прокси
- Через
urllib3.Retry(...)включитьrespect_retry_after_header=Trueи retry на ChunkedEncodingError - Если SSE/streaming -- реализовать reconnect-логику с Last-Event-ID
Симптомы
- ETL делает 1000 запросов к одному API через `requests.get(...)`. Скрипт работает 20 минут, нагрузка на сеть из миллиона packets за TLS handshakes. Профайлер показывает, что 60% времени -- установка соединения.
Причина
Каждый `requests.get(url)` создаёт новый HTTP-клиент: TCP handshake (RTT) + TLS handshake (несколько RTT для 1.2, меньше для 1.3) + DNS lookup Без `Session()` нет connection pooling -- TCP/TLS handshakes для каждого запроса При тысячах коротких запросов это особенно заметно В контейнерах с холодным DNS-кэшем (Kubernetes без NodeLocal DNS) -- DNS добавляет ещё 50-200ms
Решение
- Использовать
requests.Session()для всех вызовов к одному хосту - В httpx --
httpx.Client()илиhttpx.AsyncClient() - Настроить
HTTPAdapter(pool_connections=N, pool_maxsize=N)для большого parallel - Включить keep-alive (
Connection: keep-alive-- default в requests/httpx, но проверить, что сервер не закрывает соединения) - Для async --
AsyncClient(http2=True)-- multiplexing нескольких запросов в одном TCP-соединении
Симптомы
- `httpx.Client(http2=True)` создаётся, но реальные запросы идут по HTTP/1.1. В DevTools или Wireshark видно, что `Application-Layer Protocol Negotiation` (ALPN) выбрал `http/1.1`. Иногда падает с `ImportError: Using http2=True, but the 'h2' package is not installed`.
Причина
Не установлены h2-extras: `pip install httpx` ставит base, для HTTP/2 нужен `pip install httpx[http2]` или `pip install h2` Сервер не поддерживает HTTP/2 -- fallback на 1.1 происходит автоматически Между клиентом и сервером есть proxy/load balancer, который не понимает h2 TLS-версия < 1.2 -- h2 требует TLS 1.2+ с ALPN
Решение
- Установить extras:
pip install 'httpx[http2]'или отдельноpip install h2 - В requirements.txt:
httpx[http2]==0.28.0 - Если h2 нужен только для конкретного endpoint -- оставить
http2=Falseдля остальных - Проверить, что сервер реально умеет HTTP/2 -- иначе нет смысла в extras
- Если за прокси -- посмотреть, не понизил ли прокси протокол
Симптомы
- `RuntimeError: There is no current event loop in thread 'MainThread'.` или `asyncio.run() cannot be called from a running event loop` при попытке использовать AsyncClient.
Причина
Async-функция вызывается напрямую без `await` или `asyncio.run` asyncio.run() внутри jupyter notebook (там уже есть event loop) Async-код в Airflow operator или другом sync-фреймворке Mix sync и async в одной функции
Решение
- В обычном скрипте:
asyncio.run(my_async_func())ровно один раз на entry point - В Jupyter: использовать
await func()прямо в cell (Jupyter имплицитно ждёт) - Если нужен sync -- использовать
httpx.Client()(sync), не AsyncClient - Внутри async-функций --
await client.get(...) - Для смешанного кода --
asyncio.run_until_complete()или libraryanyio.run()(универсальнее) - В Airflow -- TaskFlow API (async) или asyncio.run в operator (если sync)
Симптомы
- Сервер принимает JWT с `header.alg = 'none'` и пустой signature -- любой может подделать токен с произвольными claims. PyJWT по умолчанию это блокирует, но при `algorithms=['none']` или без указания `algorithms` (старые версии) -- пройдёт.
Причина
В верификации `jwt.decode(token, key, algorithms=None)` -- некоторые библиотеки трактуют как 'любой', включая none Явно указан `algorithms=['none']` -- НИКОГДА так не делать Использование `verify=False` ИЛИ `options={'verify_signature': False}` для парсинга -- но без подписи токену доверять нельзя Custom-имплементация JWT где разработчик 'забыл' проверить signature Старая библиотека jsonwebtoken (Node) до patch -- известная уязвимость 2015 года
Решение
- ВСЕГДА явно указывать
algorithms=['HS256']илиalgorithms=['RS256']-- whitelist - Никогда НЕ включать 'none' в список
- Использовать актуальный PyJWT (2.x), который защищён от этой атаки
- Для public-key (RS256/ES256) проверять, что
header.algэто asymmetric, а не HS256 (key-confusion attack) - Pre-commit с bandit/semgrep правилом на
algorithms=argument - Code review jwt.decode вызовов -- должен быть explicit algorithm
Симптомы
- После /authorize получаете ошибку `error=redirect_uri_mismatch&error_description=The+redirect+URI+in+the+request+does+not+match+a+registered+redirect+URI`.
Причина
В URL регистрации (Google Console, Auth0 Dashboard, Azure AD App) указан `https://app.example.com/callback`, а вы шлёте `https://app.example.com/callback/` (trailing slash) Регистрация на `https://`, вы шлёте `http://` (даже на localhost) URI-encoding отличается: `?return_to=%2Fhome` vs `?return_to=/home` Запрашиваете port: `https://localhost:3000/callback` vs `https://localhost/callback` Wildcard в спецификации не поддерживается (или поддерживается только сам OAuth-provider, и то ограничено)
Решение
- Скопировать redirect_uri ровно как зарегистрирован, символ-в-символ
- Зарегистрировать все варианты (с и без trailing slash, dev/staging/prod) -- если provider позволяет
- Для dev -- добавить
http://localhost:3000/callbackотдельно (не путать с prod https) - В URL не использовать encoded characters (
%20вместо пробела) если регистрация без них - Помнить:
localhostи127.0.0.1-- это разные URIs для большинства провайдеров - Документировать список валидных URIs в README, чтобы dev'ы знали
Симптомы
- ETL-скрипт перебирает страницы вечно -- `next_cursor` не меняется или возвращается тот же. В логах одна и та же страница повторяется. Memory растёт от accumulated items.
Причина
Сервер возвращает `next_cursor: null` или отсутствие поля при конце -- клиент это не обрабатывает Используется один и тот же offset вместо инкремента Cursor имеет одинаковую опаковую строку, клиент думает 'есть next', но реально это loop Использован сравнение `if next_cursor:` где сервер возвращает empty string `''` -- иногда trueбоп `has_more` в ответе игнорируется, идёт только по cursor
Решение
- Всегда проверять
if next_cursor is None: break(не простоif next_cursor:-- empty string бывает truthy?) - Помимо cursor -- проверять
has_moreесли сервер его возвращает - Поддерживать
seen_cursorsset -- детектить повторение - Установить max_pages cap (10000) -- защита от runaway
- Логировать
next_cursorкаждой итерации -- при дебаге видно loop - Если возможно -- использовать stable cursor (например, max(id) seen) на client-side
Симптомы
- Скачиваете полный список юзеров через offset-pagination, в финальном dataset у некоторых юзеров несколько копий, у некоторых записей не хватает. Total в первой странице ≠ собранные строки.
Причина
Между запросами страниц 1 и 5 на сервере были inserts/deletes -- данные сдвинулись Sort не stable: на двух запросах сервер возвращает в разном порядке (без явного ORDER BY с tiebreaker) У offset нет atomic snapshot -- каждая страница это отдельный SELECT Источник данных постоянно меняется (insert-heavy таблица)
Решение
- Перейти на cursor/keyset-pagination -- стабильно при изменениях
- Если есть immutable timestamp поле (
created_at) -- фильтровать?created_at[lt]=<запомненный момент>чтобы новые inserts не сдвигали - Деупликация на client-side по
idпосле сборки - Если API поддерживает -- запрашивать snapshot-time (
?as_of=<timestamp>в некоторых API) - Скачивать в read-replica с pinned snapshot (если работаете напрямую с DB, не API)
- При full-export -- использовать другой механизм (BigQuery export, S3 dump, не пагинация)
Симптомы
- Регулярка для парсинга `Link: <url1>; rel="next", <url2>; rel="last"` пропускает страницы или вытаскивает кривые URLs. На последней странице header отсутствует, ваш код падает с KeyError.
Причина
Своя регулярка не учитывает все варианты quoting и whitespace Link header может содержать запятые внутри URL (encoded или нет) -- split по запятой ломается Не проверили наличие header перед доступом (`r.headers['Link']` падает на последней странице) rel может быть в различных форматах: `rel="next"`, `rel=next`, `rel="prev next"` (множественные rel)
Решение
- Использовать
requests.utils.parse_header_links()-- стандарт-комплиант парсер - Или библиотека
linkheader(поддерживает все edge cases) - Всегда
r.headers.get('Link', '')-- на последней странице header может отсутствовать - Поддерживать loop через
while 'next' in links-- итерации, пока не закончились - Логировать предыдущий и текущий URL -- поможет найти ошибку парсинга
- Для GitHub API -- использовать
PyGithubSDK, он сам реализует пагинацию
Симптомы
- Запрос GraphQL вида `{ users { id orders { id } } }` -- сервер делает 1 запрос на users + N запросов на orders для каждого user. На 1000 users -- 1001 SQL query, страница загружается 30 секунд.
Причина
Resolver для `orders` написан наивно: `db.query('SELECT * FROM orders WHERE user_id = ?', user.id)` для каждого user отдельно Нет DataLoader-паттерна (batching + caching внутри одного request) GraphQL по природе позволяет N+1 -- он не знает структуру SQL Это server-side проблема, client этого не видит -- медленно и всё
Решение
- Если client API -- не ваш сервер: попросить team настроить DataLoader (Python: aiodataloader, strawberry-dataloader)
- Если вы pisете GraphQL server -- реализовать DataLoader для каждого relation
- Альтернатива --
query_plannerкоторый видит весь query и генерирует JOIN'ы - На client'е -- стараться не запрашивать nested-fields если они не нужны
- Использовать GraphQL persisted queries -- конкретный operationId известен заранее, проще оптимизировать
- Кэшировать на client'е через apollo-style cache normalization
Симптомы
- `grpc._channel._InactiveRpcError: <_InactiveRpcError of RPC that terminated with: status = StatusCode.DEADLINE_EXCEEDED details = "Deadline Exceeded">`. Запрос не успел отработать за указанный timeout.
Причина
Слишком короткий `timeout=` параметр в stub-вызове Сервер реально медленный (тяжёлая query, перегрузка) Промежуточная сеть медленная (cross-region, congested link) Нет circuit breaker -- bad downstream висит, все запросы timeoutятся Streaming RPC: сервер замолчал между messages, клиент ждёт по deadline
Решение
- Увеличить timeout (
timeout=30) если работа реально долгая - Использовать deadline-propagation: client deadline уходит на server и далее
- Server-side оптимизация: профайлинг тяжёлой query, индексы, caching
- Для streaming -- keep-alive (
grpc.keepalive_time_ms) чтобы не падать на тишине - Retry на DEADLINE_EXCEEDED для idempotent RPC (через grpc-builtin retry policy в service config)
- Circuit breaker для bad downstream -- fail-fast вместо waiting
Симптомы
- Десериализация валится: `google.protobuf.message.DecodeError: Error parsing message` или `Wrong wire type` или поле пропадает, оказывается default value (0/empty string) хотя sender отправлял.
Причина
Sender и receiver скомпилированы с разных версий .proto файла -- поля переименованы или typed по-разному Tag-номер поля изменён -- receiver его игнорирует В proto2 поле было `required`, в proto3 -- `implicitly optional`; receiver получает default Внесли breaking change: удалили поле без `reserved`, переиспользовали tag для другого типа Endianness/encoding issue (редко)
Решение
- Использовать общий .proto файл (через git submodule или Buf Schema Registry)
- Никогда не reuse tag-номеров -- при удалении поля писать
reserved 3, 5; - Schema evolution rules: добавлять поля только с новыми тэгами, не менять типы существующих
- Buf для линтинга
.protoфайлов -- ловит breaking changes в CI - При выпуске нового релиза синхронизированно деплоить sender и receiver
- В Kafka с Confluent Schema Registry -- она enforce'ит compatibility
Симптомы
- Подключаетесь к `wss://stream.example.com`, через 5-30 минут exception `websockets.exceptions.ConnectionClosed: code = 1006 (connection closed abnormally [internal])`. Скрипт умер, real-time данные не приходят.
Причина
Network glitch / TCP timeout -- провайдер закрыл idle connection Сервер делает rolling deploy -- restarts WS-servers NAT/firewall сбрасывает idle TCP (типично через 5-10 мин без traffic) Без keep-alive (ping/pong) клиент и сервер не знают, что соединение мертво Достигнут серверный лимит времени соединения (типично 24 часа max)
Решение
- Включить ping/pong:
websockets.connect(url, ping_interval=20)-- server отправляет ping каждые 20s, проверяет liveness - Реализовать reconnect-loop с exponential backoff:
- ``
python while True: try: async with websockets.connect(url) as ws: async for m in ws: process(m) except (websockets.ConnectionClosed, OSError): await asyncio.sleep(min(60, 2**attempt))`` - Поддерживать last_received_id или last_event_id -- после reconnect не пропускать события
- Server-side: настроить более длительный idle timeout для legitimate clients
- Использовать SSE если bi-directional не нужен -- лучше переживает дисконнекты через Last-Event-ID
- В Kubernetes -- sticky session на Ingress (sessionAffinity), иначе reconnect попадает на другой pod
Симптомы
- Server-Sent Events работают локально, но через nginx-proxy события доставляются раз в 5-30 секунд пачкой -- а не сразу как сервер их emit'нул. У вас 'real-time' дашборд с лагом.
Причина
nginx по умолчанию буферизует proxy response (`proxy_buffering on`) -- собирает несколько event'ов, потом flush Размер буфера достаточно большой, чтобы накопить несколько events перед flush Cloudflare / CDN тоже буферизует SSE Server использует `text/plain` вместо `text/event-stream` -- nginx не распознаёт как stream WSGI/ASGI server (gunicorn) тоже может буферизовать
Решение
- На сервере: добавить response header
X-Accel-Buffering: no-- nginx это уважает - В nginx-конфиге для SSE-location:
proxy_buffering off; proxy_cache off; chunked_transfer_encoding on; - Установить
proxy_read_timeout 1h;-- иначе nginx закроет на idle stream - Если Cloudflare -- отключить proxy для SSE-endpoint (DNS-only) или использовать enterprise feature
- Использовать gunicorn
--worker-class geventили uvicorn для async -- handle long-lived connections - Альтернатива -- WebSocket (CDN их обычно поддерживают without buffering)
Симптомы
- Получаете webhook от Stripe/GitHub/Slack, считаете HMAC от body -- не совпадает с signature header. Webhook реально valid, но verify падает. Решение 'отключить проверку' -- security hole.
Причина
Сравниваете HMAC после JSON-parse'а -- теряется whitespace оригинального body FastAPI/Flask по умолчанию парсят body -- request.body() vs request.json() Sender использует timestamp + body для signing (Stripe format), вы -- только body Не учитываете кодировку: body как `str` vs `bytes` Используете wrong secret (test vs live, prod vs staging) `==` вместо `hmac.compare_digest()` -- теоретически timing attack, но functional работает
Решение
- Использовать ИМЕННО raw body (
request.body()в FastAPI/Flask), не parsed/re-serialized - Stripe:
t=...,v1=...format -- signing payloadt.body, не просто body - GitHub:
X-Hub-Signature-256: sha256=...-- pure HMAC of body - Slack:
v0:timestamp:body-- нужно конкатенировать с prefix - Прочитать документацию провайдера ВНИМАТЕЛЬНО -- формат у каждого свой
- Использовать
hmac.compare_digest()для constant-time comparison - Tests с recorded payloads + known signature -- проверять до production
Симптомы
- Читаете `pq.read_table('data/year=2026/month=05')` -- `OSError: Schema in file ...month=05/part-2.parquet:\nname: string\nage: int32\nDoes not match Schema in file ...month=05/part-1.parquet:\nname: string\nage: int64`.
Причина
Файлы записаны разными writer'ами (Python, Spark, Java) с разными dtypes (int32 vs int64) Schema evolution: новые файлы добавили поле, старые без него Writer изменил precision у decimal или scale (`decimal(10,2)` vs `decimal(12,4)`) В nested-структуре одно из полей пропало или поменяло тип Pandas `to_parquet` без явного schema -- типы выводятся из данных и могут плавать
Решение
- Использовать explicit unified schema при чтении:
- ``
python schema = pa.schema([('id', pa.int64()), ('name', pa.string())]) table = pq.read_table('data/', schema=schema)`` - При записи всегда указывать explicit schema -- не оставлять auto-inference
- Для DuckDB/Polars:
read_parquet(..., union_by_name=True)объединит по именам столбцов - Bulk-convert старые файлы в новую schema (rewrite через pyarrow)
- При работе со Spark --
mergeSchema=Trueопция (spark.read.option('mergeSchema', 'true').parquet(...)) -- медленно, но решает - В data lake -- schema registry (Delta Lake, Iceberg) для гарантированной consistency
Симптомы
- В Kafka с Confluent Schema Registry новый producer пишет с обновлённой schema. Consumer падает: `SerializationException: ... Could not find class ... Incompatible schema`.
Причина
В новой schema удалено поле без default -- старые consumers не могут читать Изменён тип поля (int -> long, string -> bytes) -- incompatible Required-поле сделано optional без default Несовместимое значение `compatibility` policy в Schema Registry (FULL / BACKWARD / FORWARD) Не зарегистрировали новую schema до отправки сообщений с ней
Решение
- Schema evolution rules (для BACKWARD compatibility, default):
- - Можно добавлять поле с default value
- - Можно удалять поле, у которого был default
- - НЕЛЬЗЯ менять тип существующего поля
- - НЕЛЬЗЯ переименовывать поля (это удаление + добавление)
- Использовать
union [null, T]с default null для optional fields - Перед push новой schema -- проверить compatibility CLI:
kafka-avro-console-producer ... --property schema.registry.url=... - Если breaking change необходим -- создать новый topic с новой schema (не upgrade old)
- Контролировать compatibility через CI:
confluent-kafka-pythonсSchemaRegistryClient.test_compatibility()
Симптомы
- После `pd.read_parquet('data.parquet')` колонка `category` стала `object`, `int64` стал `float64` (из-за NaN), даты в naive вместо UTC. После `df.to_parquet('out.parquet')` и обратно -- типы поплыли.
Причина
Pandas до 2.0 не имел nullable integers -- null приводил к `float64` Pandas 2.0+ имеет `Int64` (nullable), но не используется по умолчанию при `read_parquet` Category dtype не сохраняется в обычном parquet (сохраняется только через `engine='pyarrow'` с `use_dict=True`) Datetime в parquet -- naive (UTC), в pandas timezone-aware теряется Decimal columns конвертируются в float по умолчанию
Решение
- Pandas 2.0+ с
dtype_backend='pyarrow'-- нативные nullable types -
pd.read_parquet('data.parquet', dtype_backend='pyarrow')-- всё в pyarrow-types - Категории сохраняются если
df.to_parquet(use_dict=True)или через pyarrowdictionaryencoding - Decimal -- указывать explicit schema через pyarrow
- Datetime timezone -- всегда писать timezone-aware (
pd.to_datetime(..., utc=True)) - Альтернатива -- polars (нативно работает с pyarrow types, без pandas type confusion)
- Если нужна точная сохранность -- использовать pyarrow напрямую, без pandas-конверсии
Симптомы
- Тесты с pytest-recording / vcrpy все зелёные, но прод-вызовы к API падают. Разработчик: 'у меня всё работает локально'. Cassette файл год назад был сохранён, API за это время поменялся.
Причина
Cassette зафиксировал старый response, реальный API теперь возвращает другую структуру В .gitignore забыли cassette -- лежит на одной машине, на CI её нет Record mode 'once' навсегда фиксирует первый запуск -- новые requests не пишутся Кассеты замаскировали реальное deprecation API -- тесты не ловят drift Cassette с production-токеном (secret leakage) -- нужно ротировать токен и regenerate
Решение
- Расписать cron-job (weekly) -- re-record все cassettes, посмотреть diff в PR
- Использовать
record_mode='new_episodes'-- добавляются новые requests, существующие не перезаписываются - Для критичных API -- иметь параллельный contract-test (schemathesis, jsonschema validation) который идёт к реальному API
- В .pre-commit-config.yaml добавить cassette-age check -- fail если cassette > 90 дней
- Документировать, как обновлять cassettes:
make refresh-cassettes - Filter sensitive из cassettes:
filter_headers=['Authorization'],filter_post_data_parameters=['password']
Симптомы
- Тест с `@responses.activate` упал: `ConnectionError: Connection refused` или попытка обращения к `https://api.example.com`. Внутри теста зарегистрирован mock, но requests почему-то его не увидел.
Причина
URL в `responses.add(..., url='https://api.example.com/users')` не точно совпадает с реальным URL в коде (trailing slash, query params) Метод не совпадает: mock на GET, код делает POST Использован `responses.add_callback` -- должен быть `responses.add(method=...)` Используется httpx, а не requests -- responses работает только для requests; для httpx нужен respx Mock зарегистрирован после вызова requests.get (порядок) Вложенная функция/импорт делает запрос до декоратора
Решение
- Точное соответствие URL -- включая trailing slash и query
- Использовать
match=[matchers.query_param_matcher(...)]для гибкого query matching - Для httpx -- переходить на respx:
@respx.mockиrespx.get(url).mock(return_value=httpx.Response(...)) - В debug print
responses.calls-- что было вызвано на самом деле - Проверить порядок: декоратор должен быть SAME function, что делает запрос
- Использовать
assert_all_requests_are_fired=True(default) -- упадёт если registered mock не вызван
Симптомы
- Тесты `jsonschema.validate(response.json(), schema)` валят билды на каждом минорном API-апдейте. Команда отключает или smart'но скипает контракт-тесты -- теряется смысл.
Причина
Слишком строгая schema -- `additionalProperties: false`, при любом добавлении поля API ломает тест В тесте захардкожен exact value (`response.json()['version'] == '1.0.0'`) -- апдейт версии ломает В schema указан exact format (`format: 'date'`), API теперь возвращает 'date-time' -- fail Schema хранится в проекте отдельно от API -- drift'ит Tests для каждого field exact match, вместо проверки наличия и типа
Решение
- Использовать
additionalProperties: true(default) -- позволять API добавлять поля без ломки тестов - Required-list только для критичных полей, не для всех
- Avoid hardcoded values в схеме -- только типы и форматы
- Schema fetch'ить из API в реальном времени:
schema = requests.get(f'{api}/openapi.json').json()-- всегда актуальна - Использовать schemathesis для property-based contract testing -- он сам генерит запросы по схеме
- Для breaking changes -- versioning API, и тесты на каждую версию отдельно
- В CI -- два уровня: pre-merge schema check (строгий, internal), post-deploy contract check (lenient, external)