Перейти к содержанию
Learning Platform
Глоссарий
Troubleshooting

Troubleshooting — REST API & Data Formats для Junior Data Engineer

База знаний типичных ошибок курса REST API & Data Formats для Junior Data Engineer.

Категория

Показано 44 из 44 ошибок

Симптомы

  • При запросе через 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)

Решение

  1. Получить корпоративный CA сертификат и указать его: requests.get(url, verify='/path/to/corp-ca.pem') или env-var REQUESTS_CA_BUNDLE=/path/to/corp-ca.pem
  2. Для системных certs обновить certifi: pip install -U certifi
  3. На macOS python.org-installer: запустить /Applications/Python\ 3.x/Install\ Certificates.command
  4. Использовать системный truststore (Python 3.10+): pip install truststore и truststore.inject_into_ssl() -- тянет certs из ОС включая корпоративные CA
  5. Только для локального дебага (НИКОГДА в prod): requests.get(url, verify=False) плюс urllib3.disable_warnings() -- но это убирает защиту от MITM
  6. Если сервер с самоподписью -- конвертировать его 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 для нового домена

Решение

  1. Если используете IP вместо hostname -- переключиться на hostname или добавить запись в /etc/hosts
  2. Если cert старый -- попросить ops/devops обновить сертификат с правильными SAN
  3. Если перенаправление через proxy/CDN -- проверить настройку SNI на промежуточном узле
  4. Никогда в prod: отключить hostname check (urllib3 InsecureRequestWarning); только для дебага конкретной machine
  5. Через 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 (нужен перезапуск)

Решение

  1. Если сервер на TLS 1.0/1.1 -- попросить admin обновить (deprecated с 2020); как временный workaround в Python создать SSLContext с minimum_version=TLSVersion.TLSv1 через custom HTTPAdapter
  2. Если у клиента старый OpenSSL -- обновить Python или OpenSSL (brew upgrade openssl, на Linux apt install свежую версию)
  3. Если cipher suite mismatch -- явно указать допустимые cipher'ы в SSLContext.set_ciphers()
  4. Через requests-adapter с custom ssl_context можно зафиксировать TLS-версию и cipher'ы
  5. Если проблема с 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

Решение

  1. Использовать json= параметр в requests/httpx -- он сериализует и проставит Content-Type автоматически
  2. Если шлёте уже сериализованный JSON через data= -- обязательно явный header: headers={'Content-Type': 'application/json'}
  3. Для vendor MIME: headers={'Content-Type': 'application/vnd.api+json'} плюс data=json.dumps(payload)
  4. Проверить, что отдаёт сервер по умолчанию: возможно требует Accept: application/json тоже
  5. В 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, библиотека правильно резолвит и видит 'другой хост'

Решение

  1. Сначала найти canonical URL без редиректа: r = requests.head(url, allow_redirects=True); canonical = r.url -- обращаться сразу туда
  2. Реализовать собственный hook на redirect: subclass от requests.Session.rebuild_auth, который не срезает header при доверенных хостах
  3. Использовать allow_redirects=False и обрабатывать редиректы вручную, прокидывая Authorization осознанно
  4. Попросить API не делать cross-host redirects для авторизованных endpoints -- это плохая практика на их стороне
  5. Никогда не пробрасывайте 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

Решение

  1. В FastAPI: добавить CORSMiddleware с правильным allow_origins, allow_methods, allow_headers, allow_credentials
  2. В Express: app.use(cors({origin: 'https://app.example.com', credentials: true}))
  3. В nginx как proxy перед API: добавить add_header Access-Control-Allow-Origin ... для OPTIONS и для основного запроса
  4. Помнить: Access-Control-Allow-Origin: * НЕ работает вместе с Access-Control-Allow-Credentials: true -- указывать точный origin
  5. Если запрос ОТ Python (а не браузера) -- CORS неактуален, ошибка не воспроизводится, в curl всё работает; не путать с server-side rejection
  6. Для 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` в будущем (часы клиента ушли)

Решение

  1. Проверить exp claim -- если прошёл, запросить новый токен через refresh
  2. Удостовериться в формате header: Authorization: Bearer <token> точно так, с пробелом и capital 'B'
  3. Сверить audience и issuer с тем, что ожидает API -- попросить документацию
  4. Проверить env: prod-токен только для prod, staging -- для staging
  5. Если clock skew: настроить NTP на хосте (timedatectl status на linux, sntp на mac)
  6. Если ключи 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

Решение

  1. 401: запросить новый токен, проверить format header, sync clock
  2. 403: токен валиден, но не хватает scope/role -- запросить токен с правильным scope, или попросить admin дать роль
  3. При 403 от WAF/прокси (по Server: cloudflare в headers): это не от API, проверить IP whitelist, geo-restriction
  4. При 403 на собственный ресурс (/me) -- баг в API, в норме не должно быть
  5. Всегда читать WWW-Authenticate header в 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

Решение

  1. Читать detail[].loc и detail[].msg -- точные локация и причина
  2. Проверить, что Content-Type: application/json (см. content-type-not-set)
  3. Сверить отправляемые поля с /openapi.json (#/components/schemas/<Model>)
  4. Для path-int -- передавать число, не строку: /users/42 не /users/abc
  5. Если model отвергает extra-fields (extra='forbid') -- убрать лишнее или попросить разработчиков ослабить ограничение
  6. Использовать 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 выше квоты

Решение

  1. Использовать exponential backoff с jitter, если Retry-After нет: sleep = random.uniform(0, 2**attempt)
  2. Если Retry-After есть -- респектить ровно его
  3. Прочитать X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset headers (где-то они называются RateLimit-* без X-)
  4. Поддерживать client-side rate limiter (aiolimiter для asyncio, pyrate-limiter для sync) -- не upирать в server-side лимит
  5. Если несколько процессов с тем же ключом -- координировать через Redis (redis-py + token bucket) или distributed rate limiter
  6. Запросить у 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 выше уровнем)

Решение

  1. Перед r.json() проверять r.status_code (204/304 не должны парситься как JSON)
  2. Проверять r.headers.get('Content-Type', '').startswith('application/json')
  3. Обернуть в try/except json.JSONDecodeError, логировать r.text[:500] для дебага
  4. Если сервер возвращает HTML на ошибки -- r.raise_for_status() сначала, потом r.json()
  5. Использовать r.json() if r.content else None как защиту
  6. Если 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()` ведёт к проблеме

Решение

  1. Перед сериализацией заменять NaN/Inf на None: df.replace([np.inf, -np.inf, np.nan], None)
  2. Использовать json.dumps(data, allow_nan=False) -- ловить ошибку раньше
  3. Для pandas: df.to_json(orient='records') -- NaN -> null, или df.fillna(value)
  4. Кастомный encoder: json.dumps(data, default=lambda o: None if isinstance(o, float) and (math.isnan(o) or math.isinf(o)) else str(o))
  5. На стороне 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')`

Решение

  1. Передавать default=str для quick-fix: json.dumps(data, default=str) -- но теряется тип (всё в строку)
  2. Кастомный encoder: def default(o): if isinstance(o, datetime): return o.isoformat(); ...; raise TypeError
  3. Использовать orjson -- нативно поддерживает datetime, UUID, dataclasses: orjson.dumps(data)
  4. Для Pydantic v2: model.model_dump(mode='json') или model.model_dump_json()
  5. Для Decimal -- конвертировать в str (сохраняет precision) или float (теряется): json.dumps(d, default=lambda o: str(o) if isinstance(o, Decimal) else None)
  6. 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')`

Решение

  1. Читать с encoding='utf-8-sig' -- Python автоматически вырежет BOM
  2. В pandas: pd.read_csv('data.csv', encoding='utf-8-sig')
  3. Если уже прочитано: df.columns = df.columns.str.replace('\ufeff', '')
  4. При записи -- open('out.csv', 'w', encoding='utf-8') (без -sig) -- БЕЗ BOM
  5. В preprocessing pipeline: sed -i '1s/^\xEF\xBB\xBF//' data.csv (Linux) удалит BOM
  6. Запретить 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

Решение

  1. Явно указать encoding: pd.read_csv('data.csv', encoding='cp1251') или encoding='windows-1251'
  2. Конвертировать файл один раз: iconv -f cp1251 -t utf-8 data.csv > data_utf8.csv
  3. Если encoding неоднороден -- errors='replace' или errors='ignore' (теряются символы) -- в крайнем случае
  4. Использовать chardet/charset-normalizer для авто-определения, потом проверить human
  5. Договориться с источником данных слать UTF-8 (если возможно) -- cp1251 уже legacy
  6. 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` в одном файле

Решение

  1. Правильно: писать многострочные поля в quotes -- csv.writer(f, quoting=csv.QUOTE_ALL)
  2. При чтении: pd.read_csv('data.csv', quoting=csv.QUOTE_MINIMAL, quotechar='"', escapechar='\\')
  3. Включить engine='python' в pandas -- медленнее, но строже к dialects
  4. Использовать csv.Sniffer().sniff(sample) чтобы определить dialect
  5. Если данные сломанные и нельзя пересоздать -- on_bad_lines='skip' (теряет строки) или on_bad_lines='warn' для дебага
  6. Конвертировать 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`

Решение

  1. ВСЕГДА yaml.safe_load(text) -- поддерживает только базовые типы (dict, list, str, int, float, bool, None)
  2. Для записи -- yaml.safe_dump(data) (не safe_load -- dump обычно ОК, но для симметрии)
  3. Если нужны custom-теги -- использовать SafeLoader с явно зарегистрированными constructors
  4. ruamel.yaml -- альтернатива, по умолчанию safer, плюс сохраняет comments и порядок (для редактирования YAML)
  5. 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'`

Решение

  1. Всегда заворачивать строки в кавычки в YAML: country: 'NO', version: '1.0'
  2. Использовать ruamel.yaml с typ='safe' -- YAML 1.2 по умолчанию, нет Norway
  3. Если контролируете source YAML -- добавить тест, что после parse поля string остаются string
  4. Объявлять тип в JSON Schema для config-файла -- валидация поймает 'string field is bool'
  5. 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

Решение

  1. Держать anchors в одном файле -- если нужно reuse, импортировать содержимое через шаблонизатор
  2. Использовать Helm/Jinja/envsubst для шаблонизации YAML с includes
  3. Docker Compose: extends поля + x-* extension fields в одном файле
  4. Kubernetes: Kustomize patches и base/overlay structure
  5. Python: явно загружать оба файла и merge dict -- {**base, **override}
  6. Никогда не пытайтесь сделать !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, которая преобразовала пробелы в табы

Решение

  1. Заменить табы на пробелы: expand -t 2 config.yaml > config_fixed.yaml
  2. В vim: :set expandtab tabstop=2 shiftwidth=2 + :retab
  3. В VS Code: settings.json -- "editor.insertSpaces": true, "editor.tabSize": 2, для [yaml]
  4. Pre-commit с yamllint -- ловить до коммита
  5. .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)

Решение

  1. Если DNS -- проверить /etc/resolv.conf, использовать публичный DNS (1.1.1.1, 8.8.8.8)
  2. Если TCP -- проверить firewall (telnet api.example.com 443 или nc -zv ...), security group, network policy
  3. Если timeout -- проверить routing (traceroute api.example.com), запросить ops доступ
  4. Если behind proxy -- настроить HTTPS_PROXY=http://proxy:8080 python script.py или requests.get(url, proxies={'https': 'http://...'})
  5. В Docker -- проверить, что контейнер имеет outbound (docker run --rm alpine wget https://example.com)
  6. Поставить 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

Решение

  1. ВСЕГДА requests.get(url, timeout=10) -- total/read timeout 10 сек
  2. Лучше: timeout=(3.05, 27) -- (connect, read)
  3. В httpx: httpx.Timeout(connect=5, read=10, write=5, pool=2) или просто timeout=10.0
  4. Pre-commit hook + ruff S113 (request-without-timeout) -- ловит до коммита
  5. Wrapper-функция, заворачивающая requests.get с обязательным timeout -- энфорсит на уровне кода
  6. В 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

Решение

  1. Найти root cause через logging redirect-chain -- обычно очевидно, какие два URL bounce
  2. Если auth-related -- обеспечить cookie/header передачу через Session (см. auth-header-lost-on-redirect)
  3. Уменьшить session.max_redirects = 5 чтобы быстрее падать вместо долгого hung
  4. Если редирект НЕ нужен -- allow_redirects=False, обрабатывать в коде
  5. Сообщить ops/devs API о circular redirect -- это баг на их стороне
  6. Проверить, нет ли в 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`

Решение

  1. Retry на ChunkedEncodingError -- это transient error
  2. Использовать stream=True для больших ответов, обрабатывать chunk-by-chunk
  3. Поднять read_timeout: возможно сервер медленно отвечает
  4. Если работаете с прокси -- увеличить keep-alive timeout у прокси
  5. Через urllib3.Retry(...) включить respect_retry_after_header=True и retry на ChunkedEncodingError
  6. Если 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

Решение

  1. Использовать requests.Session() для всех вызовов к одному хосту
  2. В httpx -- httpx.Client() или httpx.AsyncClient()
  3. Настроить HTTPAdapter(pool_connections=N, pool_maxsize=N) для большого parallel
  4. Включить keep-alive (Connection: keep-alive -- default в requests/httpx, но проверить, что сервер не закрывает соединения)
  5. Для 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

Решение

  1. Установить extras: pip install 'httpx[http2]' или отдельно pip install h2
  2. В requirements.txt: httpx[http2]==0.28.0
  3. Если h2 нужен только для конкретного endpoint -- оставить http2=False для остальных
  4. Проверить, что сервер реально умеет HTTP/2 -- иначе нет смысла в extras
  5. Если за прокси -- посмотреть, не понизил ли прокси протокол

Симптомы

  • `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 в одной функции

Решение

  1. В обычном скрипте: asyncio.run(my_async_func()) ровно один раз на entry point
  2. В Jupyter: использовать await func() прямо в cell (Jupyter имплицитно ждёт)
  3. Если нужен sync -- использовать httpx.Client() (sync), не AsyncClient
  4. Внутри async-функций -- await client.get(...)
  5. Для смешанного кода -- asyncio.run_until_complete() или library anyio.run() (универсальнее)
  6. В 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 года

Решение

  1. ВСЕГДА явно указывать algorithms=['HS256'] или algorithms=['RS256'] -- whitelist
  2. Никогда НЕ включать 'none' в список
  3. Использовать актуальный PyJWT (2.x), который защищён от этой атаки
  4. Для public-key (RS256/ES256) проверять, что header.alg это asymmetric, а не HS256 (key-confusion attack)
  5. Pre-commit с bandit/semgrep правилом на algorithms= argument
  6. 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, и то ограничено)

Решение

  1. Скопировать redirect_uri ровно как зарегистрирован, символ-в-символ
  2. Зарегистрировать все варианты (с и без trailing slash, dev/staging/prod) -- если provider позволяет
  3. Для dev -- добавить http://localhost:3000/callback отдельно (не путать с prod https)
  4. В URL не использовать encoded characters (%20 вместо пробела) если регистрация без них
  5. Помнить: localhost и 127.0.0.1 -- это разные URIs для большинства провайдеров
  6. Документировать список валидных 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

Решение

  1. Всегда проверять if next_cursor is None: break (не просто if next_cursor: -- empty string бывает truthy?)
  2. Помимо cursor -- проверять has_more если сервер его возвращает
  3. Поддерживать seen_cursors set -- детектить повторение
  4. Установить max_pages cap (10000) -- защита от runaway
  5. Логировать next_cursor каждой итерации -- при дебаге видно loop
  6. Если возможно -- использовать 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 таблица)

Решение

  1. Перейти на cursor/keyset-pagination -- стабильно при изменениях
  2. Если есть immutable timestamp поле (created_at) -- фильтровать ?created_at[lt]=<запомненный момент> чтобы новые inserts не сдвигали
  3. Деупликация на client-side по id после сборки
  4. Если API поддерживает -- запрашивать snapshot-time (?as_of=<timestamp> в некоторых API)
  5. Скачивать в read-replica с pinned snapshot (если работаете напрямую с DB, не API)
  6. При 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)

Решение

  1. Использовать requests.utils.parse_header_links() -- стандарт-комплиант парсер
  2. Или библиотека linkheader (поддерживает все edge cases)
  3. Всегда r.headers.get('Link', '') -- на последней странице header может отсутствовать
  4. Поддерживать loop через while 'next' in links -- итерации, пока не закончились
  5. Логировать предыдущий и текущий URL -- поможет найти ошибку парсинга
  6. Для GitHub API -- использовать PyGithub SDK, он сам реализует пагинацию

Симптомы

  • Запрос 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 этого не видит -- медленно и всё

Решение

  1. Если client API -- не ваш сервер: попросить team настроить DataLoader (Python: aiodataloader, strawberry-dataloader)
  2. Если вы pisете GraphQL server -- реализовать DataLoader для каждого relation
  3. Альтернатива -- query_planner который видит весь query и генерирует JOIN'ы
  4. На client'е -- стараться не запрашивать nested-fields если они не нужны
  5. Использовать GraphQL persisted queries -- конкретный operationId известен заранее, проще оптимизировать
  6. Кэшировать на 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

Решение

  1. Увеличить timeout (timeout=30) если работа реально долгая
  2. Использовать deadline-propagation: client deadline уходит на server и далее
  3. Server-side оптимизация: профайлинг тяжёлой query, индексы, caching
  4. Для streaming -- keep-alive (grpc.keepalive_time_ms) чтобы не падать на тишине
  5. Retry на DEADLINE_EXCEEDED для idempotent RPC (через grpc-builtin retry policy в service config)
  6. 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 (редко)

Решение

  1. Использовать общий .proto файл (через git submodule или Buf Schema Registry)
  2. Никогда не reuse tag-номеров -- при удалении поля писать reserved 3, 5;
  3. Schema evolution rules: добавлять поля только с новыми тэгами, не менять типы существующих
  4. Buf для линтинга .proto файлов -- ловит breaking changes в CI
  5. При выпуске нового релиза синхронизированно деплоить sender и receiver
  6. В 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)

Решение

  1. Включить ping/pong: websockets.connect(url, ping_interval=20) -- server отправляет ping каждые 20s, проверяет liveness
  2. Реализовать reconnect-loop с exponential backoff:
  3. ``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)) ``
  4. Поддерживать last_received_id или last_event_id -- после reconnect не пропускать события
  5. Server-side: настроить более длительный idle timeout для legitimate clients
  6. Использовать SSE если bi-directional не нужен -- лучше переживает дисконнекты через Last-Event-ID
  7. В 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) тоже может буферизовать

Решение

  1. На сервере: добавить response header X-Accel-Buffering: no -- nginx это уважает
  2. В nginx-конфиге для SSE-location: proxy_buffering off; proxy_cache off; chunked_transfer_encoding on;
  3. Установить proxy_read_timeout 1h; -- иначе nginx закроет на idle stream
  4. Если Cloudflare -- отключить proxy для SSE-endpoint (DNS-only) или использовать enterprise feature
  5. Использовать gunicorn --worker-class gevent или uvicorn для async -- handle long-lived connections
  6. Альтернатива -- 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 работает

Решение

  1. Использовать ИМЕННО raw body (request.body() в FastAPI/Flask), не parsed/re-serialized
  2. Stripe: t=...,v1=... format -- signing payload t.body, не просто body
  3. GitHub: X-Hub-Signature-256: sha256=... -- pure HMAC of body
  4. Slack: v0:timestamp:body -- нужно конкатенировать с prefix
  5. Прочитать документацию провайдера ВНИМАТЕЛЬНО -- формат у каждого свой
  6. Использовать hmac.compare_digest() для constant-time comparison
  7. 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 -- типы выводятся из данных и могут плавать

Решение

  1. Использовать explicit unified schema при чтении:
  2. ``python schema = pa.schema([('id', pa.int64()), ('name', pa.string())]) table = pq.read_table('data/', schema=schema) ``
  3. При записи всегда указывать explicit schema -- не оставлять auto-inference
  4. Для DuckDB/Polars: read_parquet(..., union_by_name=True) объединит по именам столбцов
  5. Bulk-convert старые файлы в новую schema (rewrite через pyarrow)
  6. При работе со Spark -- mergeSchema=True опция (spark.read.option('mergeSchema', 'true').parquet(...)) -- медленно, но решает
  7. В 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 до отправки сообщений с ней

Решение

  1. Schema evolution rules (для BACKWARD compatibility, default):
  2. - Можно добавлять поле с default value
  3. - Можно удалять поле, у которого был default
  4. - НЕЛЬЗЯ менять тип существующего поля
  5. - НЕЛЬЗЯ переименовывать поля (это удаление + добавление)
  6. Использовать union [null, T] с default null для optional fields
  7. Перед push новой schema -- проверить compatibility CLI: kafka-avro-console-producer ... --property schema.registry.url=...
  8. Если breaking change необходим -- создать новый topic с новой schema (не upgrade old)
  9. Контролировать 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 по умолчанию

Решение

  1. Pandas 2.0+ с dtype_backend='pyarrow' -- нативные nullable types
  2. pd.read_parquet('data.parquet', dtype_backend='pyarrow') -- всё в pyarrow-types
  3. Категории сохраняются если df.to_parquet(use_dict=True) или через pyarrow dictionary encoding
  4. Decimal -- указывать explicit schema через pyarrow
  5. Datetime timezone -- всегда писать timezone-aware (pd.to_datetime(..., utc=True))
  6. Альтернатива -- polars (нативно работает с pyarrow types, без pandas type confusion)
  7. Если нужна точная сохранность -- использовать 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

Решение

  1. Расписать cron-job (weekly) -- re-record все cassettes, посмотреть diff в PR
  2. Использовать record_mode='new_episodes' -- добавляются новые requests, существующие не перезаписываются
  3. Для критичных API -- иметь параллельный contract-test (schemathesis, jsonschema validation) который идёт к реальному API
  4. В .pre-commit-config.yaml добавить cassette-age check -- fail если cassette > 90 дней
  5. Документировать, как обновлять cassettes: make refresh-cassettes
  6. 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 (порядок) Вложенная функция/импорт делает запрос до декоратора

Решение

  1. Точное соответствие URL -- включая trailing slash и query
  2. Использовать match=[matchers.query_param_matcher(...)] для гибкого query matching
  3. Для httpx -- переходить на respx: @respx.mock и respx.get(url).mock(return_value=httpx.Response(...))
  4. В debug print responses.calls -- что было вызвано на самом деле
  5. Проверить порядок: декоратор должен быть SAME function, что делает запрос
  6. Использовать 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, вместо проверки наличия и типа

Решение

  1. Использовать additionalProperties: true (default) -- позволять API добавлять поля без ломки тестов
  2. Required-list только для критичных полей, не для всех
  3. Avoid hardcoded values в схеме -- только типы и форматы
  4. Schema fetch'ить из API в реальном времени: schema = requests.get(f'{api}/openapi.json').json() -- всегда актуальна
  5. Использовать schemathesis для property-based contract testing -- он сам генерит запросы по схеме
  6. Для breaking changes -- versioning API, и тесты на каждую версию отдельно
  7. В CI -- два уровня: pre-merge schema check (строгий, internal), post-deploy contract check (lenient, external)