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

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

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

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

Ошибки драйвера: duplicate key и таймауты

mongo duplicate key — ошибка, с которой продакшен обычно знакомит раньше остальных: два запроса вставляют один и тот же email под уникальным индексом, и второй получает отказ. Классификация ошибок mongo golang-сервиса — это не разбор строк лога, а работа с типами драйвера: mongo.IsDuplicateKeyError и код 11000, WriteException и BulkWriteException с полями WriteError, ServerSelectionError при выключенной базе, IsTimeout и IsNetworkError для таймаутов, ErrNoDocuments для пустых FindOne. В этом уроке соберём карту ошибок драйвера v2 и прикрепим к каждому типу бизнес-решение: какой статус вернуть клиенту, что написать в лог и что вообще ретраить.

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

Сцена, знакомая каждому, кто выводил сервис в прод: хендлер регистрации проверил 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 короче и покрывает больше случаев.

Дубликат превращается в бизнес-ошибку

GO
users := 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.InsertedID

InsertOne на дубликате возвращает 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, ReplaceOnemongo.IsDuplicateKeyError(err)
BulkWriteExceptionдубликат и другие сбои в InsertMany или BulkWriteIsDuplicateKeyError — true при дубликате хотя бы у одной операции; детали в WriteErrors
ServerSelectionErrorнет подходящего живого сервера за serverSelectionTimeout — 30 секунд по умолчаниюmongo.IsTimeout(err) — true; errors.As для разбора причины
CommandError с кодом 50сервер не уложился в лимит времени: MaxTimeMSExpiredmongo.IsTimeout(err) вернёт true; поля Code и Name для деталей
ErrNoDocumentsFindOne по пустой выборкеerrors.Is(err, mongo.ErrNoDocuments)

Таймауты: три источника с разными лицами#

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

Клиент с коротким сервер-селекшном и операция с дедлайном

GO
client, 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 есть, а флага таймаута может не быть. Два вопроса — «медленно» и «оборвалось» — отвечают разными предикатами, и склеивать их в один разбор строк не стоит.

Сентинелы, ретраи и границы классификации#

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

Правила обработки ошибок одним списком#

  • Оборачивайте ошибки через 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))

Соберите классификатор ошибок

Восстановите проверки: дубликат, не найдено, медленно, сеть потеряна.

res, err := users.InsertOne(ctx, u) if mongo.(err) { return ErrEmailTaken } err = users.FindOne(ctx, bson.M{"email": u.Email}).Decode(&got) if (err, mongo.ErrNoDocuments) { return ErrUserNotFound } if mongo.(err) { return ErrDBSlow } if mongo.(err) { return ErrConnectionLost }

Соберите маппер ошибки в HTTP-статус

Расставьте строки функции классификации по порядку: от самых частных причин к общей.

Строки

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

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

Карточки: ошибки драйвера

Термин

mongo.IsDuplicateKeyError

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

true для кодов 11000, 11001 и 12582, а для mongos — 16460 с E11000 в сообщении. На BulkWriteException отвечает true, если дубликат найден хотя бы у одной операции пачки.

1 / 6

Карта собрана: дубликаты распознаёт 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 и добавьте юнит-тест на классификатор — он проверяется без базы за миллисекунды.

Личные заметкиПусто
Хранятся только в вашем браузере (MongoDB · Ошибки драйвера: duplicate key и таймауты)

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

Зачем 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: это ваша собственная отмена, предикат таймаута на неё не отвечает, и ретраить её бессмысленно — отменивший уже всё решил.

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

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

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

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

Zerolog в Go: быстрое JSON-логирование

Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.

  • Писать структурированные JSON-логи с нуля аллокаций
  • Выбирать уровни и поля события осознанно
  • Настраивать ConsoleWriter для dev и JSON для прода
  • Строить сублоггеры и контекстные поля на запрос