Содержание урока · 10 разделов
- Duplicate key: код 11000 и IsDuplicateKeyError
- WriteException и BulkWriteException: где живут коды
- Карта ошибок драйвера: что возвращается и как ловить
- Таймауты: три источника с разными лицами
- IsTimeout и IsNetworkError: два классификатора
- Сентинелы, ретраи и границы классификации
- Правила обработки ошибок одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сцена, знакомая каждому, кто выводил сервис в прод: хендлер регистрации проверил email на валидность, вставил документ и вдруг получил ошибку — а в очереди уже висит тот же адрес из параллельного запроса. Пользователь видит 500, дежурный видит стек, а виновата одна строка уникального индекса. Такие сюрпризы дорого обходятся ровно до момента, когда обработка ошибок становится такой же привычной частью кода, как сам запрос. Драйвер возвращает не анонимные строки, а типизированные значения: у ошибок записи есть свои структуры с кодами, у сбоев сети — свои, у пустых выборок — отдельный сентинел. Стандартная библиотека даёт для всего этого errors.Is и errors.As, а драйвер добавляет три классификатора-хелпера. Пройдём по карте в порядке частоты: дубликаты, структуры ошибок записи, таймауты, сеть и сентинелы. Каждый разбудим сценарием из жизни: регистрация пользователя с email, который уникален по индексу из урока про индексы.
Duplicate key: код 11000 и IsDuplicateKeyError#
В семнадцатом уроке вы сделали email уникальным: CreateOne с опцией unique создал индекс, и вторая вставка того же адреса упала. Сервер отвечает на дубликат кодом 11000, а драйвер превращает его в готовый предикат: mongo.IsDuplicateKeyError(err) возвращает true для кодов 11000, 11001 — дубликат при обновлении — и 12582, особого случая с capped-коллекцией. Для шардированных кластеров есть четвёртый путь: mongos упаковывает дубликат в код 16460, и хелпер узнаёт его по подстроке E11000 в сообщении. Ключевое свойство — работа с пачками: на ошибке BulkWriteException хелпер вернёт true, если дубликат обнаружен хотя бы у одной операции из пачки. Сообщение сервера при этом содержательное: E11000 duplicate key error collection с именем индекса email_1 и значением ключа — сразу видно, какой именно адрес оказался занят, и в логе это ценнее любого стека. Duplicate key — не авария, а бизнес-сигнал: адрес занят, клиенту честнее отдать 409, чем 500. Есть и вторичный путь проверки: errors.As в CommandError с равенством Code и 11000 — пригодится, когда хелпера под рукой нет, но IsDuplicateKeyError короче и покрывает больше случаев.
Дубликат превращается в бизнес-ошибку
GOusers := client.Database("shop").Collection("users")
res, err := users.InsertOne(ctx, bson.D{
{Key: "email", Value: u.Email},
{Key: "name", Value: u.Name},
})
if mongo.IsDuplicateKeyError(err) {
// сервер назвал индекс и значение: email_1 dup key
return ErrEmailTaken
}
if err != nil {
return fmt.Errorf("insert user: %w", err)
}
_ = res.InsertedIDInsertOne на дубликате возвращает WriteException с WriteError кода 11000, и IsDuplicateKeyError распознаёт его без ручного вскрытия структур. Первая ветка переводит сбой в доменную ошибку ErrEmailTaken, которую хендлер отображает как 409. Всё остальное заворачивается в fmt.Errorf с %w — цепочка сохранится для errors.Is выше по стеку.
WriteException и BulkWriteException: где живут коды#
За предикатами стоят конкретные структуры, и знание их полей отличает точную обработку от гадания. Ошибки одиночных записей — InsertOne, UpdateOne, UpdateMany, DeleteOne, DeleteMany, ReplaceOne — приезжают типом WriteException: внутри WriteConcernError, если не подтвердилась запись, срез WriteErrors и метки. Каждый WriteError несёт Index — позицию операции в срезе, переданном в InsertMany или BulkWrite, — поля Code и Message и исходный ответ сервера. Пачки живут по своим правилам: InsertMany и BulkWrite возвращают BulkWriteException, где каждый элемент среза — BulkWriteError с тем же WriteError и Request, моделью, которая вызвала сбой. Отсюда практическое правило mongo duplicate key-обработки: предикат говорит «есть дубликат», а детали — какой документ, какой индекс — ищите циклом по WriteErrors. Отдельная ветка — CommandError: сбой самой команды, например в Find или Aggregate. У него поле Code имеет тип int32, Name — кодовое имя от сервера вроде DuplicateKey или MaxTimeMSExpired, Message, Wrapped и Raw с исходным ответом. Оба типа протаскиваются через fmt.Errorf с %w: errors.As найдёт CommandError или WriteException на любой глубине обёртки.
Карта ошибок драйвера: что возвращается и как ловить#
| Ошибка | Когда возникает | Как ловить |
|---|---|---|
| WriteException с кодом 11000 | дубликат уникального индекса на InsertOne, UpdateOne, ReplaceOne | mongo.IsDuplicateKeyError(err) |
| BulkWriteException | дубликат и другие сбои в InsertMany или BulkWrite | IsDuplicateKeyError — true при дубликате хотя бы у одной операции; детали в WriteErrors |
| ServerSelectionError | нет подходящего живого сервера за serverSelectionTimeout — 30 секунд по умолчанию | mongo.IsTimeout(err) — true; errors.As для разбора причины |
| CommandError с кодом 50 | сервер не уложился в лимит времени: MaxTimeMSExpired | mongo.IsTimeout(err) вернёт true; поля Code и Name для деталей |
| ErrNoDocuments | FindOne по пустой выборке | errors.Is(err, mongo.ErrNoDocuments) |
Пачка из десяти документов уезжает в InsertMany, второй — дубликат. В режиме ordered, который включён по умолчанию, сервер останавливает вставку на сбойной позиции: восемь оставшихся документов в коллекцию не попадут, а наружу приедет одна BulkWriteException с единственным WriteError. Команда прошла «с ошибкой», но пятьдесят процентов данных тихо не доехали. Если полнота пачки важнее порядка, то переходите на unordered через SetOrdered(false) и разбирайте WriteErrors по Index — тогда сервер дойдёт до конца и отчитается по каждому сбою отдельно.
Таймауты: три источника с разными лицами#
Первый источник — ваш собственный контекст: context.WithTimeout на операцию истёк, и драйвер прерывает работу с context.DeadlineExceeded. Второй — сервер-селекшн: перед каждой операцией драйвер выбирает подходящий живой сервер, и если за serverSelectionTimeout тот не нашёлся, возвращается ServerSelectionError. По умолчанию на выбор даётся тридцать секунд — константа defaultServerSelectionTimeout в недрах драйвера, — а текст ошибки выглядит характерно: server selection error: context deadline exceeded, current topology с перечнем серверов и последней сетевой причиной вроде connection refused. Настраивается это через SetServerSelectionTimeout или параметр serverSelectionTimeoutMS в строке подключения. Третий источник — клиентский SetTimeout: единый таймаут операций на весь клиент, механизм CSOT. Дедлайн контекста операции главнее: клиентское значение применяется только тогда, когда у контекста своего дедлайна нет. Клиентский таймаут уезжает на сервер как maxTimeMS, и его превышение оборачивается кодом 50, MaxTimeMSExpired, — это тоже таймаут, хотя родился он на сервере. Рядом живёт SetConnectTimeout — лимит на установку соединения, тоже тридцать секунд по умолчанию, а ноль означает «без лимита».
Клиент с коротким сервер-селекшном и операция с дедлайном
GOclient, err := mongo.Connect(options.Client().
ApplyURI("mongodb://localhost:27017").
SetServerSelectionTimeout(5*time.Second))
if err != nil {
return fmt.Errorf("connect: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err = client.Database("shop").Collection("users").
FindOne(ctx, bson.M{"email": u.Email}).Err()
if mongo.IsTimeout(err) {
log.Printf("selection: %v", err) // server selection error: ... current topology: ...
return ErrDBSlow
}SetServerSelectionTimeout сжимает ожидание живого сервера с тридцати секунд до пяти. Дедлайн контекста в две секунды истечёт раньше — берётся минимальный из двух лимитов, — но наверх приедет ServerSelectionError, у которой причина лежит в Wrapped, а текст содержит текущую топологию. IsTimeout распознаёт таймаут по цепочке без разборов строк.
IsTimeout и IsNetworkError: два классификатора#
mongo.IsTimeout отвечает на вопрос «стоит ли повторить позже»: он поднимается по цепочке ошибок и возвращает true, если встретит context.DeadlineExceeded, внутренний ErrDeadlineWouldBeExceeded драйвера, ошибку ожидания соединения из пула WaitQueueTimeoutError, командный код 50 MaxTimeMSExpired, сетевой интерфейс net.Error с флагом Timeout, либо метки NetworkTimeoutError и ExceededTimeLimitError. mongo.IsNetworkError устроен иначе: он проверяет ровно одну метку, NetworkError, которую драйвер вешает на сбои обмена по уже установленному соединению — обрыв, зависший сокет, провал рукопожатия. Отсюда контринтуитивная пара, проверенная по исходникам v2.3.0: на ServerSelectionError IsTimeout отвечает true, потому что DeadlineExceeded сидит в цепочке, а IsNetworkError — false, потому что до сетевого обмена дело не дошло и метку никто не ставил. Server selection вообще происходит до первого байта запроса, поэтому выключенная база проявляется таймаутом выбора, а не сетевой ошибкой. Обрыв посреди операции — наоборот: метка NetworkError есть, а флага таймаута может не быть. Два вопроса — «медленно» и «оборвалось» — отвечают разными предикатами, и склеивать их в один разбор строк не стоит.
Ночной деплой остановил mongod, а у запросов контексты без дедлайна. Каждый вызов честно ждёт полные тридцать секунд сервер-селекшна и падает — и так по каждому запросу. Если таймаут хендлера три секунды, то пользователь давно ушёл, а горутина ещё ждёт, копя утечки. Если у вас нет выделенной минуты на ожидание базы, то ставьте SetServerSelectionTimeout заметно меньше пользовательских таймаутов и проверяйте Ping на старте: падение за миллисекунды дешевле падения за полминуты.
Сентинелы, ретраи и границы классификации#
Кроме структур, драйвер держит несколько сентинелов, сравниваемых через errors.Is. Главный — mongo.ErrNoDocuments со словами mongo: no documents in result: FindOne.Decode по пустой выборке возвращает именно его, и это нормальный ответ, а не сбой — ветка «не найдено» пишется через errors.Is, как в уроке про FindOne. ErrClientDisconnected выпадает при обращении к клиенту после Disconnect — симптом жизненного цикла, а не сети. ErrNilDocument и ErrEmptySlice — ошибки программирования: nil-документ в CRUD и пустой срез в InsertMany, и такие ветки чинятся в коде, а не ретраятся. Теперь ретраи. Дубликат не ретраится никогда: это ответ предметной области, а для гонок апсерта лекарство известно из десятого урока — повторная попытка с тем же upsert или чтение существующего документа. При сетевом сбое во время записи драйвер сам делает одну повторную попытку — механизм retryable writes — так что ваш слой видит ошибку уже после её исчерпания. context.Canceled — это отмена, а не таймаут: IsTimeout на него не сработает и повторять его не нужно. Граница классификации упирается в HTTP: 409 на дубликат, 503 и 504 на таймауты и сеть, 404 на ErrNoDocuments, 500 на всё остальное без попыток угадывания.
В репозитории дубликат оборачивают, в сервисе оборачивают ещё раз, в хендлере проверяют errors.Is — и ничего не находит. Причина: один из слоёв завернул ошибку через fmt.Errorf без %w, и цепочка оборвалась — предикаты драйвера читают только непрерывные обёртки. Если заворачиваете, то всегда с %w и без fmt.Errorf на месте, где ошибку теряют вовсе. Правило простое: логируйте копию, возвращайте оригинал.
Правила обработки ошибок одним списком#
- Оборачивайте ошибки через fmt.Errorf с %w: errors.Is и errors.As читают только непрерывную цепочку.
- Duplicate key — бизнес-сигнал: 409 клиенту, ретрай с новым ключом или повторное чтение, но не 500.
- Таймауты и сеть классифицируйте mongo.IsTimeout и mongo.IsNetworkError, а не поиском слов в сообщении.
- У каждой внешней операции — свой дедлайн через context.WithTimeout, а не один контекст на жизнь сервиса.
- Держите serverSelectionTimeout меньше пользовательских таймаутов: выключенная база должна падать первой.
- ErrNoDocuments — штатный ответ FindOne: отдельная ветка errors.Is, а не общий обработчик сбоев.
Проверка: код 11000
Что означает код 11000 в WriteError от сервера?
Проверка: ServerSelectionError
Когда драйвер возвращает ServerSelectionError?
Что напечатают два предиката?
Прочитайте код и выберите, что он выведет:
// mongod остановлен; у ctx дедлайн 2 секунды
err := coll.FindOne(ctx, bson.M{"email": u.Email}).Err()
fmt.Println(mongo.IsTimeout(err), mongo.IsNetworkError(err))Соберите классификатор ошибок
Восстановите проверки: дубликат, не найдено, медленно, сеть потеряна.
Соберите маппер ошибки в HTTP-статус
Расставьте строки функции классификации по порядку: от самых частных причин к общей.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: ошибки драйвера
Карта собрана: дубликаты распознаёт IsDuplicateKeyError, пачки вскрываются через WriteErrors, таймауты и сеть отвечают двумя разными предикатами, а пустые выборки живут на errors.Is с ErrNoDocuments. Дальше по курсу эта карта будет работать молча: в уроке про слой репозитория классификация спрячется за доменными ошибками, а в тестах те же ветки проверяются на живой тестовой базе. Уникальный индекс, который сегодня превратил вставку в 409, ставился в уроке про индексы — перечитайте раздел про unique, если сообщение сервера с именем индекса кажется незнакомым. Для гонок апсерта — счётчиков и синхронизации — повторите десятый урок: там двойная вставка лечится атомарным upsert и одной упавшей попыткой. Тем, кто работает с Postgres параллельно, полезно сравнить: в pgx тот же классификатор выглядит как SQLSTATE 23505 — идея «ошибка записи как бизнес-сигнал» переносится один в один. Всю карту стоит прожить руками: песочница сайта не исполняет mongo-go-driver, поэтому поднимите MongoDB локально в Docker — одна команда из второго урока — остановите контейнер и посмотрите своими глазами, как выглядит ServerSelectionError с пятью секундами вместо тридцати.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Duplicate key распознаёт mongo.IsDuplicateKeyError: коды 11000, 11001, 12582 и mongos-вариант 16460 с E11000.
- Одиночные записи возвращают WriteException, пачки — BulkWriteException; детали ищите в WriteErrors по полям Index, Code, Message.
- CommandError — сбой команды: Code int32, Name от сервера, Message, Wrapped и Raw; достаётся errors.As через любую обёртку.
- ServerSelectionError означает отсутствие живого сервера за serverSelectionTimeout — 30 секунд по умолчанию; причина в Wrapped.
- IsTimeout видит дедлайны, WaitQueueTimeoutError, MaxTimeMSExpired и сетевые таймауты; IsNetworkError — только метку NetworkError.
- На выключенной базе IsTimeout вернёт true, а IsNetworkError — false: сервер-селекшн до обмена данными не доходит.
- ErrNoDocuments — штатный ответ FindOne по пустой выборке; сравнение только через errors.Is.
- Ретраить таймауты и сеть — да, дубликаты и отменённые контексты — нет; обёртка ошибок всегда через fmt.Errorf с %w.
Домашнее задание
Соберите маппер classify из урока и пропустите через него три сценария на живой базе: дубль email по уникальному индексу, остановленный контейнер MongoDB и операцию с контекстом на сто миллисекунд. Для каждого сценария запишите, какой предикат сработал и какой статус вернулся, затем сравните с таблицей урока. После этого сломайте цепочку нарочно: заверните ошибку в fmt.Errorf без %w и убедитесь, что errors.Is перестал находить ErrNoDocuments. Верните %w и добавьте юнит-тест на классификатор — он проверяется без базы за миллисекунды.
Личные заметкиПусто
Вопросы и ответы по уроку
Зачем IsDuplicateKeyError, если можно проверить код ошибки руками?
Ручная проверка видит один тип: она найдёт 11000 в WriteException и промолчит на BulkWriteException, где коды лежат в срезе. Хелпер читает всю структуру, знает mongos-вариант 16460 с подстрокой E11000 и поднимается по цепочке обёрток через errors.As. Одна строка вместо трёх веток, каждая из которых умеет устаревать.
Почему IsNetworkError не срабатывает на ServerSelectionError?
IsNetworkError проверяет метку NetworkError, а её драйвер ставит только на сбои обмена по уже установленному соединению — обрыв, провал рукопожатия, зависший сокет. Сервер-селекшн до обмена не доходит, и в цепочке остаётся context.DeadlineExceeded. Поэтому выключенная база — это IsTimeout без IsNetworkError, и это осмысленный ответ, а не недоработка.
Какие таймауты выставлять в реальном сервисе?
Три уровня с разными обязанностями: serverSelectionTimeout ниже пользовательских таймаутов, чтобы выключенная база падала первой; дедлайн контекста на каждую операцию из HTTP-запроса; SetTimeout как страховка для вызовов без своего контекста. Соединение оставьте на дефолтных тридцати секундах — медленный первый коннект к удалённой реплике лечится прогревом на старте.
Чем context.DeadlineExceeded отличается от драйверных таймаутов?
Внешне — ничем важным: дедлайн контекста может приехать и голым, и завёрнутым в ServerSelectionError с описанием топологии. IsTimeout прячет эту разницу и отвечает одинаково. Реальное отличие в стороне context.Canceled: это ваша собственная отмена, предикат таймаута на неё не отвечает, и ретраить её бессмысленно — отменивший уже всё решил.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Zerolog в Go: быстрое JSON-логирование
Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.
- Писать структурированные JSON-логи с нуля аллокаций
- Выбирать уровни и поля события осознанно
- Настраивать ConsoleWriter для dev и JSON для прода
- Строить сублоггеры и контекстные поля на запрос