Как отображать исключения Python в статусы gRPC без утечки внутренних данных¶
У gRPC шестнадцать кодов ошибок помимо успешного статуса, а у вашего сервиса сотня типов исключений: кому-то нужно задать соответствие. Плохая реализация создаёт две проблемы: клиент не отличает временную ошибку от постоянной, а текст исключения уходит по сети любому вызывающему. Я пропустил одиннадцать исключений через четыре конфигурации сервера и прочитал ответы клиента. В двух оказался пароль БД.
Результаты получены в эксперименте статьи. Версии: grpc-server-kit 0.1.1, grpcio 1.83.1, Python 3.13.
Если отображения вообще нет¶
ValueError UNKNOWN "Unexpected <class 'ValueError'>: amount must be positive"
PermissionError UNKNOWN "Unexpected <class 'PermissionError'>: token lacks scope orders:write"
ConnectionError UNKNOWN "Unexpected <class 'ConnectionError'>: could not connect to
postgresql://orders:hunter2@db.internal:5432/orders"
RuntimeError UNKNOWN "Unexpected <class 'RuntimeError'>: ledger write failed against
postgresql://orders:hunter2@db.internal:5432/orders"
Все ошибки становятся UNKNOWN, все сообщения исключений — публичными.
UNKNOWN создаёт эксплуатационную проблему: клиент не отличает неверный запрос от недоступной БД, и политике повторов не на что опереться. Повторять всё — превратить плохой запрос в лавину; не повторять ничего — показать пользователю временный сбой.
Сообщение создаёт проблему безопасности, вполне реальную. Два исключения содержат строку подключения, потому что драйвер включает её в ошибку. Никто не планировал публиковать её. Стандартное поведение gRPC передаёт представление исключения в деталях статуса: удобно для отладки, не подходит серверу.
Стандартное отображение¶
ValueError INVALID_ARGUMENT 'Invalid request data'
PermissionError PERMISSION_DENIED 'Permission denied'
FileNotFoundError NOT_FOUND 'Resource not found'
TimeoutError DEADLINE_EXCEEDED 'Deadline exceeded'
NotImplementedError UNIMPLEMENTED 'Method is not implemented'
KeyError INTERNAL 'Internal server error'
ConnectionError INTERNAL 'Internal server error'
RuntimeError INTERNAL 'Internal server error'
Пять стандартных исключений получают соответствующие смыслу статусы. Всё остальное становится INTERNAL; сообщения заменяются безопасными строками по статусу. Пароли исчезают.
Важен стандарт INTERNAL: неизвестное отображению исключение считается ошибкой сервиса и превращается в INTERNAL без подробностей. Клиент узнаёт, что проблема не в нём и идентичный повтор, вероятно, не поможет. Настоящее сообщение остаётся в журнале вместе с traceback.
Отображение — контракт клиента, а не форматирование¶
Полезным отображение становится после добавления ошибок вашего сервиса:
OrderNotFound NOT_FOUND 'Resource not found'
OrderAlreadyPaid FAILED_PRECONDITION 'Failed precondition'
RateLimited RESOURCE_EXHAUSTED 'Resource exhausted'
ConnectionError UNAVAILABLE 'Service unavailable'
Каждая строка говорит, что делать клиенту; код — машиночитаемая часть этого решения:
| Статус | Действие клиента |
|---|---|
INVALID_ARGUMENT |
Исправить запрос; не повторять неизменённым |
NOT_FOUND |
Объекта нет; повтор его не создаст |
FAILED_PRECONDITION |
Состояние не подходит; повторять только после его изменения |
PERMISSION_DENIED / UNAUTHENTICATED |
Исправить права или учётные данные; не повторять с теми же |
RESOURCE_EXHAUSTED |
При ограничении частоты выдержать паузу, затем повторить |
UNAVAILABLE |
Повторить с задержкой; другая реплика может ответить |
DEADLINE_EXCEEDED |
Время вызова истекло; работа могла выполниться или нет |
ABORTED |
Конфликт конкурентных операций; повторить на более высоком уровне |
INTERNAL |
Ошибка сервера; идентичный повтор вряд ли поможет |
Два пункта требуют особого внимания.
ConnectionError как UNAVAILABLE имеет последствия. Этот статус велит клиентам повторять. Это полезно при кратком сбое БД и опасно после часа недоступности, когда повторы мешают восстановлению. Код правильный, но именно поэтому клиенту нужны бюджет и circuit breaker, а не только число попыток.
DEADLINE_EXCEEDED не говорит, выполнена ли работа. Запись с таймаутом могла зафиксироваться. Поэтому здесь особенно нужен ключ идемпотентности на другой стороне; повтор по этому статусу безопасен только для повторяемых операций.
Не используйте ALREADY_EXISTS там, где нет дубликата. Он кажется подходящим для «этот заказ уже оплачен», но клиентская библиотека может считать его успехом — «ваша операция создания уже прошла» — и молча проглотить ситуацию.
Подробности: два уровня доверия¶
Безопасные сообщения по статусу честны, но малоинформативны: Failed precondition не объясняет, какое условие нарушено. Нужно разделить исключения на две группы:
ValueError INVALID_ARGUMENT 'Request processing failed'
ConnectionError UNAVAILABLE 'Request processing failed'
RuntimeError INTERNAL 'Request processing failed'
OrderNotFound NOT_FOUND 'OrderNotFound: order 42'
OrderAlreadyPaid FAILED_PRECONDITION 'OrderAlreadyPaid: order 42 was paid at 09:12'
RateLimited RESOURCE_EXHAUSTED 'RateLimited: 100 requests per minute'
Ошибки предметной области, определённые сервисом, могут передавать сообщение: вы специально написали его для клиента и оставили только допустимые факты. Всё остальное, включая стандартные исключения из вашего кода, получает общую строку. Их сообщения пишут библиотеки, драйверы и стандартная библиотека, авторы которых не думают об аудитории вашего API.
Полезное правило: публиковать сообщение можно только у явно разрешённого типа ошибки вашего сервиса, чей текст предназначен клиенту. Оно проверяется при ревью и сохраняет безопасное поведение, когда кто-то добавил новый тип и не подумал о публикации.
Для данных сложнее предложения используйте error_details gRPC вместо структуры внутри строки details. Машиночитаемые сведения в trailing metadata позволяют клиенту выбирать поведение, а человеческий текст остаётся текстом.
Для доменной ошибки можно вернуть заранее выбранное объяснение клиенту. Неожиданному внутреннему исключению нужны общий ответ и подробная запись на сервере.
Где чему место¶
- Отображение живёт в транспортном слое, не в обработчиках. Обработчик, ловящий своё исключение ради статуса, переносит проектирование API в бизнес-логику; следующий выберет иначе.
- Предметная область не импортирует
grpc. Она выбрасываетOrderNotFound, который нужен и HTTP-точке входа того же сценария. О двух транспортах — отдельная статья. - Намеренные прерывания проходят без изменений.
context.abortуже выбрал статус; слой отображения не должен его переписывать, а сборщик ошибок — считать багом. Это ловушка порядка перехватчиков. - Журнал получает всё необходимое для диагностики: тип, сообщение, traceback, correlation id. Общий текст по сети имеет смысл, только если подробности доступны в защищённом журнале.
Инструменты¶
Отображение, безопасные сообщения и фабрика подробностей есть в grpc-server-kit. Перехватчик объединяет вашу карту со стандартной, по умолчанию заменяет details безопасной строкой, журналирует настоящее исключение с traceback и не трогает намеренные прерывания. Пять встроенных типов — отправная точка; главная часть карты — ошибки вашей предметной области.
Одиннадцать исключений, два пароля, одна карта соответствий.