---
title: "Ошибки драйвера: duplicate key и таймауты"
description: "Ошибки драйвера mongo в Go: duplicate key и код 11000, WriteException и BulkWriteException, ServerSelectionError, IsTimeout, IsNetworkError и ErrNoDocuments."
course: "MongoDB в Go: официальный драйвер mongo-driver v2"
url: "https://golangmaster.ru/courses/mongo-driver/lessons/20-oshibki-drivera/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «MongoDB в Go: официальный драйвер mongo-driver v2», урок 20 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/mongo-driver/lessons/20-oshibki-drivera/*

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

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

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

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

> **Живое место: тридцать секунд тишины.** Ночной деплой остановил 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 от сервера?

- Коллекция, в которую идёт вставка, не существует
- У пользователя нет прав на запись в коллекцию
- ✅ Нарушено ограничение уникального индекса: документ с таким ключом уже есть
- Сервер не уложился в лимит времени операции

11000 — код duplicate key: нарушен уникальный индекс. Драйвер распознаёт его через mongo.IsDuplicateKeyError вместе с кодами 11001, 12582 и mongos-вариантом 16460 с E11000 в сообщении.

### Проверка: ServerSelectionError

**Вопрос:** Когда драйвер возвращает ServerSelectionError?

- Фильтр запроса не совпал ни с одним документом
- ✅ Драйвер не нашёл подходящий живой сервер за serverSelectionTimeout — 30 секунд по умолчанию
- Пул соединений исчерпал лимит соединений
- Сервер вернул ошибку валидации BSON

Перед каждой операцией драйвер выбирает сервер; если за отведённое время живого подходящего сервера нет, возвращается ServerSelectionError с описанием топологии и причиной в Wrapped. Лечится настройкой SetServerSelectionTimeout и диагностикой реплики.

### Что напечатают два предиката?

```go
// mongod остановлен; у ctx дедлайн 2 секунды
err := coll.FindOne(ctx, bson.M{"email": u.Email}).Err()

fmt.Println(mongo.IsTimeout(err), mongo.IsNetworkError(err))
```

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

- true true
- ✅ true false
- false true
- false false

Выбор сервера не уложится в дедлайн и вернёт ServerSelectionError, в цепочке которой сидит context.DeadlineExceeded — IsTimeout даст true. Метки NetworkError у этой ошибки нет: она ставится только на сбоях обмена по установленному соединению, поэтому IsNetworkError даст false.

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

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

```go
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
}
```

Подсказка: Хелперы драйвера начинаются с Is и читают цепочку сами; ErrNoDocuments сравнивается через стандартный errors.Is.

Правильные ответы в пропусках: `IsDuplicateKeyError`, `errors.Is`, `IsTimeout`, `IsNetworkError`

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

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

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

1. `func classify(err error) int {`
2. `  switch {`
3. `  case errors.Is(err, mongo.ErrNoDocuments):`
4. `    return http.StatusNotFound`
5. `  case mongo.IsDuplicateKeyError(err):`
6. `    return http.StatusConflict`
7. `  case mongo.IsTimeout(err), mongo.IsNetworkError(err):`
8. `    return http.StatusBadGateway`
9. `  default:`
10. `    return http.StatusInternalServerError`
11. `  }`
12. `}`

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

- **mongo.IsDuplicateKeyError** — true для кодов 11000, 11001 и 12582, а для mongos — 16460 с E11000 в сообщении. На BulkWriteException отвечает true, если дубликат найден хотя бы у одной операции пачки.
- **ServerSelectionError** — Драйвер не нашёл живой подходящий сервер за serverSelectionTimeout — по умолчанию 30 секунд. Причина лежит в Wrapped, текст начинается со слов server selection error и перечисляет топологию.
- **mongo.IsTimeout** — true, если в цепочке есть context.DeadlineExceeded, WaitQueueTimeoutError, код 50 MaxTimeMSExpired, net.Error с Timeout или метки NetworkTimeoutError, ExceededTimeLimitError.
- **mongo.IsNetworkError** — true только при метке NetworkError — сбой обмена по установленному соединению. У ServerSelectionError метки нет, поэтому на выключенной базе IsNetworkError отвечает false.
- **WriteException против BulkWriteException** — WriteException возвращают одиночные записи: InsertOne, UpdateOne, DeleteOne, ReplaceOne. BulkWriteException — BulkWrite и InsertMany; каждый BulkWriteError несёт WriteError с Index, Code, Message и модель Request.
- **SetTimeout и SetConnectTimeout** — SetTimeout — клиентский дефолт-таймаут операций, CSOT: дедлайн контекста главнее, на сервер уезжает maxTimeMS. SetConnectTimeout — лимит установки соединения, 30 секунд по умолчанию, ноль — без лимита.

Карта собрана: дубликаты распознаёт IsDuplicateKeyError, пачки вскрываются через WriteErrors, таймауты и сеть отвечают двумя разными предикатами, а пустые выборки живут на errors.Is с ErrNoDocuments. Дальше по курсу эта карта будет работать молча: в [уроке про слой репозитория](/courses/mongo-driver/lessons/21-modeli-sloj/) классификация спрячется за доменными ошибками, а в тестах те же ветки проверяются на живой тестовой базе. Уникальный индекс, который сегодня превратил вставку в 409, ставился в [уроке про индексы](/courses/mongo-driver/lessons/17-indeksy/) — перечитайте раздел про unique, если сообщение сервера с именем индекса кажется незнакомым. Для гонок апсерта — счётчиков и синхронизации — повторите [десятый урок](/courses/mongo-driver/lessons/10-upsert/): там двойная вставка лечится атомарным upsert и одной упавшей попыткой. Тем, кто работает с Postgres параллельно, полезно сравнить: в [pgx](/courses/pgx/) тот же классификатор выглядит как SQLSTATE 23505 — идея «ошибка записи как бизнес-сигнал» переносится один в один. Всю карту стоит прожить руками: песочница сайта не исполняет mongo-go-driver, поэтому поднимите MongoDB локально в Docker — одна команда из второго урока — остановите контейнер и посмотрите своими глазами, как выглядит ServerSelectionError с пятью секундами вместо тридцати.

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

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