---
title: "Обработка ошибок"
description: "Ошибки Connect RPC: connect.NewError, все 16 кодов gRPC и их HTTP-статусы, оборачивание sentinel-ошибок, разбор *connect.Error на клиенте и error details."
course: "Connect: современный RPC от Buf"
url: "https://golangmaster.ru/courses/connect/lessons/06-oshibki/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Connect: современный RPC от Buf», урок 6 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/connect/lessons/06-oshibki/*

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

До сих пор наши хендлеры возвращали только успех: заметка найдена, заметка создана. Но продакшен живёт в другом жанре: заметки не находятся, квоты исчерпываются, база недоступна, дедлайны горят. Разница между учебным и боевым сервисом измеряется не количеством фич, а качеством обработки отказов — и 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-ответа, как мы видели ещё в [уроке про протоколы](/courses/connect/lessons/04-protokoly/).

### Сервер: 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 — не синонимы: первый говорит «сессия прервана», второй — «привет не предъявлен». Этот словарь вы уже видели в таблице [первого урока](/courses/connect/lessons/01-vvedenie/) — теперь идём глубже: полный маппинг на HTTP-статусы и правила выбора.

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

| Код | HTTP-статус | Типовой смысл |
| --- | --- | --- |
| canceled | 499 Client Closed Request | вызов отменил сам клиент |
| unknown | 500 Internal Server Error | ошибка без более подходящего кода |
| invalid_argument | 400 Bad Request | запрос некорректен независимо от состояния системы |
| deadline_exceeded | 504 Gateway Timeout | дедлайн истёк до завершения вызова |
| not_found | 404 Not Found | запрошенный ресурс не существует |
| already_exists | 409 Conflict | создание ресурса, который уже есть |
| permission_denied | 403 Forbidden | данные есть, доступа нет |
| resource_exhausted | 429 Too Many Requests | исчерпан лимит или квота |
| failed_precondition | 400 Bad Request | система не в состоянии для операции |
| aborted | 409 Conflict | операция прервана, часто из-за конкуренции транзакций |
| out_of_range | 400 Bad Request | операция за пределами допустимого диапазона |
| unimplemented | 501 Not Implemented | метод не реализован или не включён |
| internal | 500 Internal Server Error | нарушен инвариант системы; для серьёзных ошибок |
| unavailable | 503 Service Unavailable | временная недоступность, стоит ретраить |
| data_loss | 500 Internal Server Error | невосстановимая потеря или порча данных |
| unauthenticated | 401 Unauthorized | нет валидных учётных данных |

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

- CodePermissionDenied — доступ запрещён
- ✅ CodeResourceExhausted — квота исчерпана, на проводе будет 429
- CodeInvalidArgument — запрос нельзя исполнять
- CodeUnavailable — сервис временно недоступен

Исчерпание квот и лимитов — это ровно ресурсная история: CodeResourceExhausted и HTTP 429, у которого даже есть семантика Retry-After для повторов. PermissionDenied означал бы «прав на создание нет никогда», InvalidArgument — «запрос кривой независимо от состояния», Unavailable — «сервер временно не отвечает», а лимит — состояние системы, а не недоступность.

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

Коды — конечный словарь, и споры «какой выбрать» случаются в каждой команде. Спецификация даёт подсказку, как рассуждать, и она сводится к трём вопросам. Первый: виноват сам запрос независимо от состояния системы? Ставьте 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-протоколе?

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

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

**Что выведет программа?**

- HTTP 500 и JSON-тело {"code": "internal"}
- ✅ HTTP 429 и JSON-тело с code: "resource_exhausted" и полем message
- HTTP 200, а код ошибки приедет в trailer-заголовках
- HTTP 403 и пустое тело ответа

Connect-протокол кодирует итог вызова в обычный HTTP-статус: resource_exhausted отображается в 429 Too Many Requests, а тело — JSON с полями code и message. Никаких трейлеров: в этом вся суть протокола, о которой мы говорили в первом уроке. gRPC-клиент получит тот же код по-другому — в grpc-status, для него это прозрачная совместимость.

### Оборачиваем свои ошибки: 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 без объяснений.

> **Голая ошибка превращается в internal.** Если хендлер вернул обычную ошибку без кода — errors.New или fmt.Errorf без обёртки — Connect-хендлер примет её за внутреннюю и отдаст клиенту код internal с HTTP 500. Для unexpected-ошибок это правильный дефолт, но он бьёт по тем случаям, где вы забыли пометить not_found или invalid_argument: пользователь увидит «внутреннюю ошибку» на пустой ввод. Правило: каждый путь возврата ошибки в хендлере — либо код, либо осознанный internal.

### Клиентская сторона: разбор *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?

- Работает быстрее на коротких цепочках
- ✅ Раскапывает *connect.Error на любой глубине цепочки обёрнутых ошибок
- Не требует импорта пакета connect
- Автоматически ретраит вызов после ошибки

Сервер и промежуточные слои могли обернуть ошибку несколько раз — прямое приведение типа сработает только на самом верхнем слое, а errors.As проходит по всей цепочке через unwrap. Это стандартный приём работы с обёрнутыми ошибками в Go, и *connect.Error поддерживает его честно. Ретраями errors.As не занимается и не обязан.

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

Два кода ведут себя особняком, и о них стоит поговорить отдельно, потому что они приходят не из бизнес-логики, а из контекста — вспомните таймауты и отмену из предыдущего урока. 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. Что увидит клиент?

- ✅ Ошибку с кодом NotFound в grpc-status — коды у обоих фреймворков одни и те же
- Ошибку Unknown: gRPC не понимает Connect-коды
- Успешный ответ с пустым сообщением
- HTTP 404 без тела: gRPC-клиент не умеет читать JSON-ошибки

Модель ошибок — из gRPC и для gRPC: handler транслирует коды в формат провода, который ожидает каждый клиент. Для gRPC-клиента это grpc-status в трейлерах, для Connect-клиента — JSON с code, для gRPC-Web — закодированные в тело трейлеры. Шестнадцать кодов везде одни — именно это делает словарь ошибок переносимым между концами экосистемы.

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

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

```go
// Сервер:
return nil, connect.___(connect.CodeNotFound, err)

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

Подсказка: Конструктор ошибки — NewError, её тип — *connect.Error, а достаёт её из цепочки стандартный errors.As.

Правильные ответы в пропусках: `NewError`, `Error`, `As`

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

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

Правильный порядок строк:

1. `Репозиторий возвращает sentinel-ошибку ErrNotFound`
2. `Хендлер ловит её через errors.Is`
3. `Хендлер возвращает connect.NewError(CodeNotFound, fmt.Errorf(..., %w))`
4. `Handler упаковывает код в формат протокола клиента`
5. `Connect-клиент получает HTTP 404 и JSON с code: not_found`
6. `Библиотека на клиенте собирает *connect.Error`
7. `Клиент раскапывает её через errors.As`
8. `Клиент ветвится по connectErr.Code() и показывает пустое состояние`

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

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

> **Прогон без сети тоже работает.** Ошибку можно почувствовать без кода: поднимите сервер из третьего урока и дёрните GetNote с несуществующим id через curl — JSON-ответ с code и message вы увидите в терминале, а в браузерной песочнице сайта интерактивы этого урока отработают те же коды без сети.

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

- **connect.NewError** — Конструктор ошибки RPC: принимает код (например, CodeNotFound) и любую Go-ошибку, возвращает *connect.Error, которую хендлер отдаёт вместо ответа.
- ***connect.Error** — Тип ошибки на обоих концах: методы Code(), Message(), Details(); поддерживает errors.Is/As через стандартный unwrap. Клиент всегда получает её при неудаче вызова.
- **16 кодов gRPC** — Фиксированный словарь: canceled, unknown, invalid_argument, deadline_exceeded, not_found, already_exists, permission_denied, resource_exhausted, failed_precondition, aborted, out_of_range, unimplemented, internal, unavailable, data_loss, unauthenticated.
- **invalid_argument vs failed_precondition** — InvalidArgument — запрос некорректен независимо от состояния системы; FailedPrecondition — запрос корректен, но система не в нужном состоянии (или OutOfRange — за границей диапазона).
- **canceled → 499** — Клиент сам прервал вызов. 499 — традиция nginx «client closed request». Родственник — deadline_exceeded → 504: дедлайн истёк. Первый не ретраят, второй можно.
- **connect.CodeOf** — Быстрый способ получить код ошибки: CodeOf(err) возвращает Code, раскапывая цепочку обёрток. Для полных подробностей — errors.As до *connect.Error и методы Code(), Message(), Details().

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

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

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

- Ошибки 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.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 означает устойчивое отсутствие прав, а не временное исчерпание квоты.
