Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 6 из 25 ~18 мин +50 XP

Обработка ошибок

Ошибки — половина протокола RPC. В Connect они строятся из кодов gRPC: connect.NewError превращает обычную Go-ошибку в *connect.Error с machine-readable кодом, который на проводе становится HTTP-статусом. Разберём все connect ошибки: 16 кодов и их маппинг на статусы, как выбрать код и не утопить детали, как разбирать ошибки на клиенте через errors.As и CodeOf.

Содержание урока · 13 разделов

До сих пор наши хендлеры возвращали только успех: заметка найдена, заметка создана. Но продакшен живёт в другом жанре: заметки не находятся, квоты исчерпываются, база недоступна, дедлайны горят. Разница между учебным и боевым сервисом измеряется не количеством фич, а качеством обработки отказов — и RPC-фреймворк обязан дать для этого общий язык. У обычных Go-ошибок такого языка нет: error — это строка, которую можно напечатать, и всё. Клиенту на другом конце провода от строки не холодно и не жарко: он не может programmatically отличить «нет такой заметки» от «база легла», не может решить, ретраить ли, не может показать пользователю внятный статус. В gRPC эта проблема решена кодами статусов, и Connect унаследовал решение целиком: в этом уроке разберём connect ошибки от и до — как сервер помечает ошибку кодом через connect.NewError, как все шестнадцать кодов отображаются в HTTP-статусы, где проходят границу invalid_argument и failed_precondition, и как connect error codes читает клиентская сторона. Если вы проходили курс по gRPC, добрая половина урока будет повторением с новым синтаксисом; остальная половина — то, что Connect добавляет от себя: осмысленные HTTP-статусы вместо трейлеров.

Почему обычной error недостаточно#

Начнём с проблемы, которую решаем. Представьте хендлер GetNote, который возвращает nil и errors.New("заметка не найдена") — выглядит честно, работает даже. Но по проводу эта ошибка превращается в кашу: протокол обязан передать её как-то стандартизованно, а передавать нечего — ни кода, ни типа, только сообщение. Клиент получит internal-error с вложенной строкой и не сможет принять ни одного решения: скрыть ли кнопку редактирования (not_found), показать ли капчу (resource_exhausted), ретраить ли через секунду (unavailable) или через минуту (deadline_exceeded). Проблема не в строках как таковых — они отлично годятся для человека, который читает лог. Проблема в границе процесса: строка не пересекает границу сервисов как сигнал, годный для ветвления. Поэтому в gRPC-мире приняли стандарт: каждая ошибка несёт один из фиксированных кодов — перечисление, известное обоим концам провода заранее. Код — это контракт: он стабилен между версиями, одинаков для Go, TypeScript и Kotlin клиентов, и именно по нему пишется логика отказов. Сообщение при коде — уже для человека, для логов и всплывающих подсказок. Connect принимает эту модель без изменений: те же коды, та же семантика, только дорога другая — не трейлеры HTTP/2, а честные статусы HTTP-ответа, как мы видели ещё в уроке про протоколы.

Сервер: connect.NewError

GO
func (s *notesServer) GetNote(ctx context.Context, req *notesv1.GetNoteRequest) (*notesv1.Note, error) {
        note, err := s.repo.FindByID(ctx, req.Id)
        switch {
        case errors.Is(err, ErrNotFound):
                // Код RPC + оборачиваем оригинал: контекст ошибки сохранится.
                return nil, connect.NewError(connect.CodeNotFound,
                        fmt.Errorf("note %q: %w", req.Id, err))
        case err != nil:
                // Внутренняя деталь: код на провод, текст — в лог.
                log.Printf("get note %q: %v", req.Id, err)
                return nil, connect.NewError(connect.CodeInternal, errors.New("internal"))
        default:
                return note, nil
        }
}

connect.NewError принимает код и любую Go-ошибку, возвращая *connect.Error. Здесь два паттерна, которые стоит запомнить. Первый: свои sentinel-ошибки репозитория (ErrNotFound) переводятся в коды RPC на границе хендлера, а оригинал заворачивается через %w — цепочка сохраняется, errors.Is у вызывающего продолжит работать. Второй: неожиданные ошибки не отдаются наружу в сыром виде — сервер логирует детали и отдаёт generic-сообщение с кодом internal, чтобы не светить структуру базы.

Шестнадцать кодов: карта#

Всего кодов шестнадцать — перечисление connect.CodeCanceled, CodeUnknown и далее по списку; пользовательских кодов в protobuf-RPC не бывает, и это сознательное решение спецификации: маленький стабильный словарь лучше большого изменчивого. Запоминать их удобно группами. Группа «виноват запрос»: invalid_argument (запрос некорректен сам по себе), out_of_range (значение за пределами допустимого), failed_precondition (запрос корректен, но система не в нужном состоянии), unauthenticated (нет валидных учётных данных), permission_denied (учётные данные есть, права — нет). Группа «виновато состояние»: not_found, already_exists, resource_exhausted, aborted (чаще всего откат транзакции), deadline_exceeded. Группа «виноват сервер или сеть»: unavailable (временно недоступен, стоит ретраить), internal (нарушен инвариант), data_loss, unknown. Отдельные две: unimplemented (метод не реализован или не включён — Connect-хендлер возвращает его сам для Unimplemented-заглушек) и canceled (вызов отменил сам клиент). Важно, что canceled и unauthenticated — не синонимы: первый говорит «сессия прервана», второй — «привет не предъявлен». Этот словарь вы уже видели в таблице первого урока — теперь идём глубже: полный маппинг на HTTP-статусы и правила выбора.

Все 16 кодов и их HTTP-статусы в Connect-протоколе#

КодHTTP-статусТиповой смысл
canceled499 Client Closed Requestвызов отменил сам клиент
unknown500 Internal Server Errorошибка без более подходящего кода
invalid_argument400 Bad Requestзапрос некорректен независимо от состояния системы
deadline_exceeded504 Gateway Timeoutдедлайн истёк до завершения вызова
not_found404 Not Foundзапрошенный ресурс не существует
already_exists409 Conflictсоздание ресурса, который уже есть
permission_denied403 Forbiddenданные есть, доступа нет
resource_exhausted429 Too Many Requestsисчерпан лимит или квота
failed_precondition400 Bad Requestсистема не в состоянии для операции
aborted409 Conflictоперация прервана, часто из-за конкуренции транзакций
out_of_range400 Bad Requestоперация за пределами допустимого диапазона
unimplemented501 Not Implementedметод не реализован или не включён
internal500 Internal Server Errorнарушен инвариант системы; для серьёзных ошибок
unavailable503 Service Unavailableвременная недоступность, стоит ретраить
data_loss500 Internal Server Errorневосстановимая потеря или порча данных
unauthenticated401 Unauthorizedнет валидных учётных данных

Пользователь превысил дневной лимит создания заметок. Какой код вернёт правильно оформленный хендлер CreateNote?

Как выбрать код: три вопроса#

Коды — конечный словарь, и споры «какой выбрать» случаются в каждой команде. Спецификация даёт подсказку, как рассуждать, и она сводится к трём вопросам. Первый: виноват сам запрос независимо от состояния системы? Ставьте invalid_argument — пустой id, отрицательная страница, битый формат. Второй: запрос корректен, но система не в том состоянии? Тогда failed_precondition — например, создание заметки в архивированном проекте; здесь же out_of_range, если суть в границах значения (страница 9999 из трёх). Спецификация прямо инструктирует выбирать между invalid_argument, failed_precondition и out_of_range именно так: первый — для ошибок, независимых от состояния, второй и третий — для зависимых от состояния и диапазона. Третий вопрос: это вообще про текущий момент или навсегда? not_found и already_exists описывают текущее состояние (может, через минуту заметка появится — ошибки не будет), а permission_denied — устойчивый вердикт. Отдельная дисциплина — у unavailable и internal: первый обещает «повтор может помочь» и разрешает ретраи, второй говорит «нарушили свой же инвариант, повтор без разбора полётов бессмыслен». Перепутаете — и нагрузочный ретрай-цикл сам устроит вам вторую аварию. Такой мини-алгоритм из трёх вопросов стоит написать в командную вики: он снимает 90 процентов споров.

Какой HTTP-ответ увидит curl при Connect-протоколе?

Прочитайте код и выберите, что он выведет:

// Хендлер:
return nil, connect.NewError(connect.CodeResourceExhausted,
        errors.New("дневной лимит заметок исчерпан"))

// Клиент из урока 05 вызывает через curl:
//   POST /notes.v1.NoteService/CreateNote
//   Content-Type: application/json

Оборачиваем свои ошибки: sentinel, %w и чистота ответов#

Теперь дисциплина на стороне сервера — три правила, которые спасут вам недели отладки. Правило первое: домен объявляет sentinel-ошибки, репозиторий их возвращает, а хендлер переводит в коды RPC. var ErrNotFound = errors.New(...) в пакете домена; FindByID возвращает её; хендлер ловит через errors.Is и оборачивает в connect.NewError(CodeNotFound, ...). Так же раскладывались бы ошибки в Gin- и Echo-проектах — мы делали ровно это в курсах по фреймворкам, меняется только тип конверта. Правило второе: сохраняйте цепочку через %w в fmt.Errorf — connect.NewError оборачивает вашу ошибку как есть, и весь контекст («note "n1": no rows») остаётся на месте; раскопать оригинал можно через errors.Is и errors.As, потому что *connect.Error поддерживает стандартный unwrap-протокол Go. Правило третье, самое частое нарушение: не отдавайте наружу внутренние детали. Ошибка драйвера базы со строкой подключения или таблицей в тексте — подарок атакующему. Внутренние ошибки логируйте целиком на сервере, а наружу — generic-сообщение и код internal. Заметьте, что сообщение ошибки едет по проводу как есть — для not_found это прекрасно (пользователь имеет право знать), для internal — только sanitized-текст. Граница «что говорить наружу» — то же решение, что в HTTP-мире: 404 с телом и 500 без объяснений.

Клиентская сторона: разбор *connect.Error#

Перейдём на другой конец провода. Клиенту Connect обещает одно: любая неудача вызова приходит как connect.Error — тип с методами Code() (код), Message() (сообщение от сервера) и Details() (структурированные приложения, о них ниже). Разбирать её можно двумя способами, и оба стоит держать в голове. Первый — прямой: errors.As(err, &connectErr), после чего connectErr.Code() == connect.CodeNotFound решает судьбу ветки. errors.As здесь работает, потому что connect.Error поддерживает стандартное разворачивание цепочки. Второй — короче: connect.CodeOf(err) возвращает код ошибки без ручного объявления переменной, удобно для быстрых проверок и логов. Плюс отдельная ветка — отмена: прерванный вызов доезжает кодом CodeCanceled, и его отличают от unavailable простым сравнением Code() — ниже увидим это в коде. Практический шаблон клиента: проверять err первым делом, ветвиться по кодам, а не по строкам сообщения (сообщение — для человека, оно меняется), и не ретраить всё подряд: ретраи уместны на unavailable и, с осторожностью, на aborted; not_found и invalid_argument не станут другим от повтора. Разбор ошибок по коду — та же культура, что и в gRPC-клиенте из курса по gRPC, только тип ошибки здесь один и он всегда локальный.

Клиент: ветвление по кодам

GO
note, err := client.GetNote(ctx, &notesv1.GetNoteRequest{Id: id})
if err != nil {
        var connectErr *connect.Error
        switch {
        case errors.As(err, &connectErr) && connectErr.Code() == connect.CodeNotFound:
                // Бизнес-сценарий: показываем пустое состояние, не ошибка по сути.
                return renderEmpty(id)
        case errors.As(err, &connectErr) && connectErr.Code() == connect.CodeCanceled:
                // Пользователь ушёл со страницы — логируем и тихо выходим.
                log.Print("canceled by client")
                return err
        default:
                // Неожиданное: логируем полностью, наружу — обобщённо.
                log.Printf("get note: %v", err)
                return fmt.Errorf("не удалось загрузить заметку: %w", err)
        }
}

Три ветки — три класса реакций: известный бизнес-код (not_found), транспортная отмена (CodeCanceled — контекст прервали сами) и всё остальное. errors.As достаёт *connect.Error из любой глубины цепочки, а connect.CodeOf(err) дал бы код ещё короче — если ветка одна. Строку Message() здесь никто не парсит: она для людей, ветвление всегда по коду.

Чем errors.As(err, &connectErr) удобнее сравнения с типом через type assertion?

Отмена и дедлайны как коды#

Два кода ведут себя особняком, и о них стоит поговорить отдельно, потому что они приходят не из бизнес-логики, а из контекста — вспомните таймауты и отмену из предыдущего урока. CodeDeadlineExceeded (HTTP 504) означает, что дедлайн истёк до завершения вызова: клиент мог выставить его сам через context.WithTimeout, мог получить унаследованный дедлайн от вышестоящего запроса, мог сервер — на проводе это одна и та же ошибка. CodeCanceled (HTTP 499) — вызов отменили: пользователь закрыл вкладку, upstream-запрос умер, кто-то вызвал cancel(). Цифра 499 не из стандартного реестра HTTP — это традиция nginx «client closed request», которую Connect-протокол унаследовал: логично, что статус для «клиент ушёл» не должен быть настоящей 5xx-ошибкой сервера. Клиентская разница между ними проста: deadline_exceeded допускает ретрай с большим таймаутом, canceled — нет, вы же сами отменили. Серверная разница важнее: в хендлере ctx.Done() надо уважать — перестать считать, откатить транзакцию, не писать полузаписи. И ещё нюанс для потоков между сервисами: если ваш хендлер дёргает другой RPC и отменяется, отмена должна распространяться вниз по цепочке контекстов — тогда 499 честно доехает до самого края, а не превратится в висящую горутину.

Error details: структурированные приложения#

Иногда одного кода и сообщения мало. Хочется приложить к ошибке машиночитаемые подробности: какие именно поля формы не прошли валидацию, какая квота исчерпана и когда обновится, какой id конфликтует. Для этого в модели ошибок gRPC есть details — массив структурированных приложений, и Connect поддерживает их нативно. Механика такая: детали — это обычные protobuf-сообщения, объявленные в схеме (например, InvalidFields с повторяющимся полем path); сервер собирает их и добавляет к *connect.Error, клиент читает список через err.Details() и декодирует каждое приложение в конкретный тип по его типу сообщения. По проводу, в JSON-формате Connect-протокола, детали приезжают массивом details рядом с code и message — то есть даже curl увидит их без специальных клиентов. Куда это применят? Валидация с перечнем полей — классика; нештатные ответы с подсказками ретрая; мультитенантные проверки с перечнем нарушенных политик. Но держите умеренность: детали — это часть контракта API, они требуют схемы и версионирования, а не произвольного текста на ходу. Что-то меняется в детали — это breaking change точно так же, как изменение message-типа. Для большинства сервисов честный набор «код + сообщение + два-три details-типа» закрывает все реальные потребности, а экзотика вроде потоковых частичных ошибок ждёт нас в уроке про стриминг.

Старый gRPC-клиент из grpc-go вызывает тот же хендлер, который вернул CodeNotFound. Что увидит клиент?

Соберите серверную и клиентскую стороны ошибки

Восстановите пометку ошибки кодом на сервере и разбор кода на клиенте.

// Сервер: return nil, connect.(connect.CodeNotFound, err) // Клиент: var connectErr *connect. if errors.(err, &connectErr) && connectErr.Code() == connect.CodeNotFound { return renderEmpty(id) }

Путь ошибки от хендлера до ветки клиента

Расставьте по порядку события между ошибкой в репозитории и реакцией клиента.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Чек-лист обработки ошибок#

  • Каждый путь возврата ошибки в хендлере помечен кодом: либо осознанно internal, либо точный код
  • Sentinel-ошибки домена переводятся в коды на границе хендлера, не глубже
  • Оригинал оборачивается через %w — цепочка errors.Is/As сохраняется
  • Внутренние детали логируются на сервере, наружу — generic-сообщение
  • Клиент ветвится по кодам через errors.As или CodeOf, а не по строкам
  • Ретраи только на unavailable и aborted; not_found и invalid_argument не ретраятся
  • Для структурированных подробностей — details по схеме, а не текст в сообщении

Карточки: ошибки Connect

Термин

connect.NewError

нажмите, чтобы перевернуть
Определение

Конструктор ошибки RPC: принимает код (например, CodeNotFound) и любую Go-ошибку, возвращает *connect.Error, которую хендлер отдаёт вместо ответа.

1 / 6

Что дальше по курсу#

Теперь наши сервисы говорят об отказах на общем языке: хендлер ставит коды, клиент ветвится по ним, HTTP-статусы честно отражают итог. Следующая ступень — делать это красиво и в одном месте: перехватчики. Логирование каждого вызова с процедурой и длительностью, проверка токенов на всех рутах разом, метрики для Prometheus — всё это живёт в interceptors, о которых седьмой урок расскажет от обёртки UnaryFunc до цепочек на сервере и клиенте. Дальше по курсу — стриминг, где у ошибок появляется потоковое измерение, браузерные клиенты на TypeScript, которые читают те же коды, и buf-тулчейн, который не даст контракту разъехаться. Программа всех двадцати пяти уроков — на странице курса. А для контраста полезно вспомнить, как те же решения выглядели в чистом gRPC: разбор статусов и таймаутов мы делали в уроке про unary-вызовы — положите оба подхода рядом, и вы увидите, что культура обработки ошибок пережила смену транспорта без единой правки в ветвлениях.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Ошибки RPC — это коды, а не строки: код известен обоим концам провода, по нему ветвится логика; сообщение — для человека.
  • connect.NewError(code, err) оборачивает любую Go-ошибку в *connect.Error; голая ошибка без кода доезжает до клиента как internal и HTTP 500.
  • Шестнадцать кодов gRPC отображаются в HTTP-статусы: not_found → 404, already_exists → 409, resource_exhausted → 429, unavailable → 503, canceled → 499, deadline_exceeded → 504.
  • Выбор кода: invalid_argument — запрос кривой сам по себе; failed_precondition/out_of_range — система не в состоянии; unavailable — ретрай уместен; internal — свой инвариант нарушен.
  • Клиент разбирает ошибки через errors.As(err, &connectErr) с методом Code() или через connect.CodeOf; отмена приезжает кодом CodeCanceled, дедлайн — CodeDeadlineExceeded.
  • Sentinel-ошибки домена переводятся в коды на границе хендлера; оригинал оборачивается %w; внутренние детали — в лог, наружу — generic-сообщение.
  • Для структурированных подробностей есть details: protobuf-сообщения по схеме, которые едут массивом рядом с code и message.

Домашнее задание

Доведите notes.v1 до честной обработки отказов: объявите sentinel-ошибки ErrNotFound и ErrLimitReached в пакете домена, верните их из репозитория-в-памяти, переведите в коды в хендлерах GetNote и CreateNote через connect.NewError с оборачиванием %w. На клиенте напишите функцию handleError(err error), которая через errors.As различает три сценария: not_found (пустое состояние), resource_exhausted (сообщение про лимит), всё остальное (лог полной цепочки и generic-сообщение). Проверьте оба пути curl-ом: существующий и несуществующий id, создание сверх лимита. Затем выпишите таблицу: пять кодов, которые использует ваш сервис, их HTTP-статусы и ваше решение по ретраям для каждого — этот артефакт войдёт в итоговый проект курса.

Личные заметкиПусто
Хранятся только в вашем браузере (Connect · Обработка ошибок)

Вопросы и ответы по уроку

Что будет, если хендлер вернёт обычную ошибку без connect.NewError?

Connect-хендлер посчитает её внутренней: клиент получит код internal и HTTP 500. Это безопасный дефолт, но бизнес-сценарии вроде not_found нужно помечать кодом явно — иначе пользователь увидит «внутреннюю ошибку» на пустой ввод.

Чем Connect-ошибки отличаются от gRPC-ошибок?

Словарь один и тот же — шестнадцать кодов gRPC. Разница в дороге: gRPC кладёт код в trailer grpc-status, Connect-протокол — в обычный HTTP-статус и JSON-тело с полями code, message и details. В Go оба конца работают с одним типом *connect.Error.

Какой код ставить на превышение лимита запросов?

CodeResourceExhausted — на проводе для Connect-клиентов это HTTP 429 Too Many Requests, семантически близкий к Retry-After. CodePermissionDenied означает устойчивое отсутствие прав, а не временное исчерпание квоты.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Fyne25 уроков~13 чпрактика25 уроков уже доступны

Fyne: десктопные и мобильные GUI на Go

Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.

  • Создавать окна и интерфейсы из виджетов
  • Управлять компоновкой: Box, Grid, Form
  • Рисовать примитивами canvas и строить свои виджеты
  • Связывать данные через binding