gRPC вернул Unknown: как отделить отказ клиента от сбоя зависимости

Пользователь просит отменить уже доставленный заказ. Сервис корректно запрещает действие, но клиент видит Unknown и предлагает повторить операцию позже. На дежурстве эту же ошибку считают техническим сбоем. Правило предметной области работает, а контракт между процессами потерял его смысл. Исправлять нужно преобразование ошибки на границе gRPC, а затем поведение клиента для каждого результата.

Начните с решения, которое должен принять клиент

Для отмены заказа полезно различать несколько ситуаций. Запрос не содержит идентификатора — пользователь или вызывающий код должен исправить вход. Заказ не найден — нужно проверить ссылку. Заказ существует, но его состояние уже запрещает отмену — повтор без изменения обстоятельств бессмысленен. Хранилище временно недоступно — возможно, действие удастся позже. Текст сообщения может быть похожим, но действия после ответа различаются.

Поэтому внутреннюю ошибку не стоит сразу превращать в строку. В слое бизнес-логики сохраните различимую причину: например, sentinel error или отдельный тип. На транспортной границе переведите её в статус. Тогда тот же сценарий можно проверить без сети, а gRPC-обработчик остаётся ответственным за внешний контракт. Такой перевод особенно полезен, когда рядом существует HTTP API: оба транспорта используют одно правило, но представляют отказ по-разному.

Преобразование в Go-обработчике

var ErrOrderMissing = errors.New("order missing")
var ErrOrderDelivered = errors.New("order delivered")

func publicError(err error) error {
    switch {
    case err == nil:
        return nil
    case errors.Is(err, context.Canceled):
        return status.Error(codes.Canceled, "request cancelled")
    case errors.Is(err, context.DeadlineExceeded):
        return status.Error(codes.DeadlineExceeded, "request deadline exceeded")
    case errors.Is(err, ErrOrderMissing):
        return status.Error(codes.NotFound, "order not found")
    case errors.Is(err, ErrOrderDelivered):
        return status.Error(codes.FailedPrecondition,
            "delivered order cannot be cancelled")
    default:
        return status.Error(codes.Internal, "cannot cancel order")
    }
}

// В сгенерированном gRPC-сервисе:
func (s *Server) CancelOrder(ctx context.Context,
    req *pb.CancelOrderRequest) (*pb.CancelOrderResponse, error) {
    if req.GetOrderId() == "" {
        return nil, status.Error(codes.InvalidArgument, "order_id is required")
    }
    if err := s.orders.Cancel(ctx, req.GetOrderId()); err != nil {
        s.logger.Printf("cancel order failed: %v", err)
        return nil, publicError(err)
    }
    return &pb.CancelOrderResponse{}, nil
}

Отмена контекста и истечение дедлайна сохраняют отдельные статусы. Остальные непредвиденные ошибки закрываются Internal; временную недоступность конкретного хранилища нужно классифицировать отдельно, если этот исход предусмотрен контрактом. Здесь errors, context, codes и status приходят соответственно из стандартной библиотеки и пакетов google.golang.org/grpc/codes и google.golang.org/grpc/status. pb обозначает пакет, который создал protoc для вашей схемы. Фрагмент показывает место подключения преобразования в реальном RPC-методе; имена полей ответа зависят от вашего proto-файла. Клиент разбирает status.Code(err), а текст сохраняет для человека. Договорённость по тексту быстро ломается от редакторской правки или перевода.

Почему нельзя выбрать один статус для всех отказов

FailedPrecondition сообщает: сначала нужно изменить состояние системы. Для доставленного заказа это означает другой бизнес-сценарий, например возврат. InvalidArgument обозначает некорректный аргумент независимо от текущего состояния. Unavailable уместен для временной недоступности, но сам по себе не разрешает слепо повторить запись: исход предыдущей попытки мог остаться неизвестным. Выбирайте статус gRPC по следующему допустимому действию клиента.

Не отправляйте клиенту полный текст ошибки базы. Он может содержать внутренние адреса, названия таблиц или значения параметров. Внутреннюю причину сохраните в журнале вместе с идентификатором запроса, а наружу верните устойчивую категорию и безопасное сообщение. Если клиенту нужны поля, пригодные для автоматического решения, договоритесь о структурированных деталях ошибки отдельно. Добавлять разбор SQL-строки на стороне клиента означает привязать его к устройству хранения.

Проверка должна доходить до вызывающей стороны

Сначала табличным тестом проверьте publicError: обёрнутая через fmt.Errorf с %w причина должна сохранить ожидаемый статус. Отдельно проверьте неизвестную ошибку: клиент не должен увидеть её внутренний текст. Затем вызовите RPC настоящим клиентом в локальном тесте. Важен статус после прохождения транспорта, поскольку промежуточный interceptor может переписать результат или обработчик может вернуть не ту ошибку.

После этого проверьте интерфейс пользователя или следующий сервис. Для отказа по состоянию он должен остановить автоматическое повторение и показать доступное действие. Для технической ошибки требуется другая ветка. Здесь заканчивается задача классификации: retry policy, идемпотентность и дедлайны проектируются следующим шагом. Полезный критерий готовности — два разных исхода больше не превращаются в одинаковое поведение клиента.

Учитывайте и права доступа. Если существование чужого заказа нельзя раскрывать, единое правило ответа должно применяться до выдачи подробной причины. Иначе статус NotFound в одном месте и FailedPrecondition в другом могут позволить угадывать состояние чужих объектов. Это решение принимает владелец API вместе с правилами авторизации. В тестах полезно иметь два заказа с одинаковым состоянием, но разными владельцами: доступный заказ проходит доменную проверку, чужой получает согласованный ответ без внутренних подробностей.

Перед переводом ошибки через транспорт полезно сохранить её причину при оборачивании через errors.Is. А решение клиента после временного сбоя разберите отдельно: безопасность повторного запроса зависит от операции, а не только от категории ошибки.

Изучать контракт вместе со сбоями

Если вам уже тесно в одном обработчике, в платном курсе по микросервисам на Go можно последовательно разобрать HTTP и gRPC, дедлайны, обработку ошибок и сценарии отказов. Дальше программа связывает взаимодействие сервисов с PostgreSQL, Kafka и восстановлением обработки. Вы будете читать и писать Go-код, сравнивая то, что получил клиент, с тем, что успела сделать система. Откройте программу и выберите следующий этап после своего HTTP API.

Продолжить на практике

  • Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗

    От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.