---
title: "Ошибки продюсера и ретраи"
description: "Ошибки продюсера Kafka в Go: WriteErrors по позициям пачки, MessageTooLargeError, ретраи Writer до MaxAttempts с бэкоффом, таймауты и что делать с непосланным."
course: "Kafka в Go: продюсеры и консьюмеры на kafka-go"
url: "https://golangmaster.ru/courses/kafka-go/lessons/12-oshibki-producer/"
published: "2026-09-19"
updated: "2026-09-29"
minutes: 18
---

# Ошибки продюсера и ретраи

*Курс «Kafka в Go: продюсеры и консьюмеры на kafka-go», урок 12 из 25. ~18 минут. Обновлено 29.09.2026. Источник: https://golangmaster.ru/courses/kafka-go/lessons/12-oshibki-producer/*

Kafka producer errors — не аварийный режим, а нормальная жизнь продюсера: брокер мигнул, лидер партиции переехал, сообщение не влезло в лимит. Разберём ретраи kafka в Writer, разбор WriteErrors по позициям, MessageTooLargeError, таймауты и судьбу пачки, которая так и не уехала.

Короткий сбой, который проще придумать, чем поймать: брокер перезапустился на триста миллисекунд, в этот момент сервис отправил пачку из пятисот событий. Что стало с пачкой — вопрос, на который у продюсера из четвёртого урока нет готового ответа. Часть сообщений могла уехать до сбоя, часть — ждать своих попыток, часть — не дожить до конца. Хорошая новость: Writer в kafka-go ретраит доставку сам, без вашего участия, и короткое моргание сети чаще всего проходит незамеченным. Плохая новость: когда попытки исчерпаны, ошибки возвращаются вызывающему — и структура у них неочевидная: не одна ошибка на вызов, а по одной на каждое сообщение пачки. Обработать их наобум — значит либо потерять события, либо создать дубликаты. В этом уроке разберём все источники ошибок продюсера по очереди: что Writer ретраит сам и сколько раз, как разобрать WriteErrors по позициям, что делать с сообщением, не влезшим в лимит, и как распоряжаться пачкой, судьба которой неизвестна.

### Ретраи kafka в Writer: MaxAttempts и бэкофф

Writer не отдаёт первую же ошибку наружу: каждая пачка получает до десяти попыток доставки — это дефолт поля MaxAttempts в WriterConfig, и документация формулирует его прямо: «try at most 10 times». Между попытками Writer ждёт бэкофф: WriteBackoffMin задаёт нижнюю границу, по умолчанию сто миллисекунд, WriteBackoffMax — верхнюю, по умолчанию секунду. Ретраятся временные неприятности: моргание сети, перееезд лидера партиции, таймаут запроса — брокерские коды вроде LeaderNotAvailable и RequestTimedOut клиент классифицирует как подлежащие повтору. Если же попытки исчерпаны, Writer сдаётся по-настоящему: ошибка возвращается из WriteMessages, и дальше решаете вы. Каждая фактическая попытка повторного отправления видна в статистике: writer.Stats() отдаёт счётчик Retries — удобный прибор, чтобы отличить тихий здоровый поток от потока, живущего на ретраях. Практическое следствие: поднимать MaxAttempts стоит вместе с пониманием, что каждая попытка — это время, которое пачка висит в памяти; опускать — вместе с буфером недоставленного из конца урока. Важно про время: бэкофф растёт от WriteBackoffMin к WriteBackoffMax, поэтому худший случай пачки — сумма попыток с сетевыми таймаутами по десять секунд на операцию; поднятый MaxAttempts растягивает и этот потолок. Синхронный код, который не готов ждать минуту на упавшем брокере, ограничивает ожидание дедлайном контекста — но тогда вступает в силу правило недетерминированного исхода из коллаута ниже. Для ретраи kafka и скорость, и живучесть — не константы, а ручки под профиль трафика.

### Writer с явными лимитами попыток

```go
writer := kafka.NewWriter(kafka.WriterConfig{
        Brokers:         []string{"localhost:9092"},
        Topic:           "orders",
        MaxAttempts:     5,
        WriteBackoffMin: 200 * time.Millisecond,
        WriteBackoffMax: 2 * time.Second,
})
defer writer.Close()

err := writer.WriteMessages(ctx, msgs...)
log.Printf("written=%d err=%v retries=%d", len(msgs), err, writer.Stats().Retries)
```

Три поля делают политику повторов явной: MaxAttempts 5 ограничивает число попыток пачки, WriteBackoffMin и WriteBackoffMax задают коридор ожидания между ними — от двухсот миллисекунд до двух секунд. После отправки в лог уезжает счётчик Retries из Stats: если он стабильно растёт, Writer живёт на повторах, и искать стоит сеть или брокера, а не вину конкретного сообщения.

> **Живое место: пачка из пятисот, ошибка на одной позиции.** История из ревью: обработчик заказов получал от WriteMessages ошибку и повторно отправлял всю пачку целиком — логично же, раз ошибка. Через месяц аналитика поймала дубликаты заказов с одним и тем же идентификатором. Разбор показал: WriteErrors содержал ненулевые ошибки на трёх позициях из пятисот, остальные четыреста девяносто семь сообщений доставились с первой попытки — а ретрай всей пачки продублировал их. Лечение — цикл по позициям: nil-элемент означает доставленное сообщение, и его трогать нельзя. Второй урок из этой истории: обрабатывать WriteErrors как единый blob так же опасно, как игнорировать их вовсе — цена ошибки проявляется не в логе, а в данных.

### WriteErrors: по одной ошибке на каждое сообщение

Синхронный WriteMessages при частичной неудаче возвращает kafka.WriteErrors — срез ошибок длиной в пачку. Позиция в срезе совпадает с позицией сообщения в исходном вызове: err[2] отвечает за msgs[2]. Nil-элемент — сообщение доставлено; ненулевой — не доставлено после всех попыток, и тип ошибки внутри — тот самый брокерский код, переведённый в Go-ошибку. В пакете kafka есть именованные коды из протокола: LeaderNotAvailable и NotLeaderForPartition на переездах лидера, UnknownTopicOrPartition на опечатках в имени топика, RequestTimedOut на просроченных запросах. Сравнение делается через errors.Is: kafka.Error — целочисленный тип, поэтому ошибки сопоставляются по значению без преобразований. Три сценария стоит различать заранее. Ошибка целиком, не WriteErrors, — значит, вызов сломался до раздачи по партициям: сеть, конфиг, слишком большое сообщение. WriteErrors со всеми ненулевыми — пачка не доехала, повторяйте всё. WriteErrors с точечными nil — повторяйте только виновников. Именно эта развилка отличает код, которому можно доверять поток платежей, от кода, который молча множит сущности.

### Проверка: nil внутри WriteErrors

**Вопрос:** WriteMessages вернул kafka.WriteErrors на пачку из трёх сообщений, при этом err[1] == nil. Что это значит?

- ✅ Второе сообщение доставлено, первое и третье — нет
- Writer сдался на втором сообщении и не отправил остальные
- Ошибка случилась на второй попытке: сообщение уедет при следующем вызове
- Так не бывает: nil-элементов в WriteErrors не бывает

WriteErrors — срез по числу сообщений пачки, и nil-элемент означает успешную доставку соответствующего сообщения. Повторять стоит только позиции с ненулевыми ошибками: ретрай всей пачки создаст дубликаты для nil-позиций — ровно та история, что разобрана в коллауте выше.

### MessageTooLargeError и таймауты пути

Отдельная ошибка на этапе планирования — kafka.MessageTooLargeError: сообщение крупнее BatchBytes или лимита брокера. Важно, что она возвращается до отправки: Writer проверяет размеры до раздачи по партициям, поэтому не уезжает ничего, даже маленькие соседние сообщения. У ошибки два полезных поля: Message — сам виновник, Remaining — пачка без него, готовая к повторной отправке без пересборки среза. Историческое примечание из исходников честно называет это поведение неудобным, но обратная совместимость важнее: ожидание такое, что программа отбросит слишком крупное сообщение и повторит остальное. Второй слой ограничений — время: у Writer есть ReadTimeout и WriteTimeout, оба по умолчанию десять секунд, на операции с брокером. Внешний бюджет задаёт контекст: дедлайн из context.WithTimeout прерывает WriteMessages в любой момент, и исход пачки в этом случае неопределён — документация предписывает считать всю пачку недоставленной и повторить позже, что при повторе может дать дубликаты. Таймауты и лимиты — не раздражители, а контуры защиты: их стоит выставить до продакшена, а не после первого инцидента с гигабайтным сообщением.

### Разбор ошибок: too large и по позициям

```go
err := writer.WriteMessages(ctx, msgs...)
if err != nil {
        var tooLarge kafka.MessageTooLargeError
        if errors.As(err, &tooLarge) {
                log.Printf("dropping offender: %d bytes", len(tooLarge.Message.Value))
                err = writer.WriteMessages(ctx, tooLarge.Remaining...)
        }
}
if err != nil {
        var werr kafka.WriteErrors
        if errors.As(err, &werr) {
                for i := range werr {
                        if werr[i] != nil {
                                log.Printf("msg %d: %v", i, werr[i])
                        }
                }
        }
}
```

Две ветки закрывают два типа отказов. errors.As распознаёт MessageTooLargeError: виновник логируется и отбрасывается, Remaining отправляется повторно — пачка без пересборки. Вторая ветка разбирает WriteErrors по позициям: nil пропускается, ненулевые попадают в лог с индексом сообщения. Такой обработчик не теряет и не дублирует события — редкое свойство для кода на проде.

> **Письмо после закрытия двери: io.ErrClosedPipe.** WriteMessages после Close возвращает io.ErrClosedPipe — Writer закрыт и новые пачки не принимает. Звучит безобидно, пока шатдаун не перемешает порядок: горутина ещё дописывает хвост дня, а deferred Close уже сработал — и хвост дня оказывается в логах вместо топика. Порядок завершения строгий: сначала отменяем контекст и дожидаемся финальных отправок, потом Close. С отменой контекста связана вторая тонкость из документации: прерванная посреди пути пачка считается недоставленной целиком, но отдельные сообщения могут доехать и после возврата — повтор такой пачки даёт дубликаты, о чём девятый урок говорил со стороны консьюмера. Кафка не даёт транзакционности на этом уровне: решать судьбу непосланного — работа вашего кода.

### Проверка: контекст отменён посреди отправки

**Вопрос:** Контекст отменён посреди WriteMessages с пачкой из сотни сообщений. Как документация kafka-go советует относиться к пачке?

- ✅ Считать всю пачку недоставленной и повторить позже — возможны дубликаты
- Считать всю пачку доставленной: отмена ничего не меняет
- Дозаписать хвост: Writer доварит отменённые сообщения сам
- Упасть с паникой: отмена внутри WriteMessages недопустима

Документация WriteMessages формулирует прямо: программа должна считать, что вся пачка провалилась, и переписать сообщения позже — что может привести к дубликатам. Отдельные сообщения при этом могут доехать уже после возврата. Итог: повтор с расчётом на дедупликацию, а не вера в аккуратный хвост.

### Что вернёт WriteMessages на этой пачке?

```go
writer := kafka.NewWriter(kafka.WriterConfig{
        Brokers:    []string{"localhost:9092"},
        Topic:      "orders",
        BatchBytes: 1000, // 1 КБ
})

msgs := []kafka.Message{
        {Value: []byte(strings.Repeat("a", 500))},
        {Value: []byte(strings.Repeat("b", 2000))},
}
err := writer.WriteMessages(ctx, msgs...)
```

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

- ✅ MessageTooLargeError до отправки: второе сообщение больше BatchBytes
- nil: Writer сам разрежет крупное сообщение на части
- WriteErrors с ошибкой только на второй позиции
- Таймаут после MaxAttempts попыток

Проверка размеров идёт до планирования отправки: сообщение крупнее BatchBytes прерывает весь вызов с MessageTooLargeError, и даже маленькое первое сообщение не уезжает. Поле Remaining ошибки содержит пачку без виновника для повторной отправки, а Message — само переполненное сообщение.

### Что делать с непосланным: буфер, outbox, метрика

Когда все попытки исчерпаны, у пачки должны появиться хозяин и план — иначе событие исчезнет в логе. Вариантов три, и они не исключают друг друга. Первый — буфер в памяти: недоставленные сообщения докладываются в локальную очередь и повторяются с паузой; дёшево, но буфер живёт ровно столько, сколько живёт процесс, и при рестарте его содержимое пропадает. Второй — транзакционный outbox: событие записывается в таблицу той же транзакцией, что и бизнес-данные, а отдельный воркер доставляет его в Kafka и помечает доставленным; дороже, но переживает что угодно, и это стандарт для платежей. Внутри outbox живёт простая дисциплина: события читаются из таблицы в порядке записи, а ключ идемпотентности страхует от повторов — идеально ровно один раз не обещает никто, но двойная запись платежа становится невозможной. Третий — осознанный дроп с метрикой: для телеметрии потеря легальна, важно лишь, чтобы счётчик недоставленных был виден на дашборде, а не в логе. Выбор делает не вкусовщина, а цена события: заказ или платёж едут через outbox, метрика — через дроп. Любому из путей нужен ключ идемпотентности в Headers: повторы на стороне продюсера, как и ретраи kafka на стороне брокера, не дают ровно один раз — дают минимум один раз.

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

- Разбирайте WriteErrors по позициям: nil-элемент — доставленное сообщение, его повторять нельзя.
- MessageTooLargeError приходит до отправки: Remaining содержит пачку без виновника.
- Попытки доставки ограничивает MaxAttempts, дефолт 10; бэкофф от WriteBackoffMin до WriteBackoffMax.
- После Close любой WriteMessages возвращает io.ErrClosedPipe — закрывайте Writer последним шагом шатдауна.
- Отмена контекста: исход пачки неизвестен, повтор — с расчётом на дубликаты.
- Непосланное — всегда решение: буфер в памяти, транзакционный outbox или дроп с метрикой, но не молчание.

### Ретраи и дубликаты: цена повторов на записи

Ретраи kafka на стороне продюсера симметричны ретраям на стороне консьюмера из девятого урока и имеют ту же цену: повтор после неизвестного исхода может создать дубликат. kafka-go не поддерживает идемпотентный режим продюсера с порядковыми номерами, как «большие» клиенты, — значит, страховка от дублей живёт в вашем коде: ключ идемпотентности в Headers, дедупликация на потребителе или upsert на приёмнике. Связь с десятым уроком прямая: при Async: true ни WriteErrors, ни MessageTooLargeError вы не увидите вовсе — ошибки оседают в Stats, а о судьбе пачки отчитывается только Completion. Поэтому первые вопросы продюсеру, который начал терять события: какой режим и где счётчик Errors. Проверить всё это против живого брокера легко локально: docker stop на брокер из третьего урока — и WriteErrors с Retries в Stats покажут больше, чем час чтения документации. В песочнице сети нет — она имитирует только чистую логику на программах в Go ниже.

### Ошибки продюсера одним столом

| Ошибка | Когда возникает | Что делать |
| --- | --- | --- |
| kafka.WriteErrors | Пачка ушла, часть сообщений не принята после всех попыток | Цикл по позициям: nil — доставлено, остальное — повтор или дроп |
| kafka.MessageTooLargeError | Сообщение больше BatchBytes; до отправки не уехало ничего | Исключить виновника, отправить Remaining |
| io.ErrClosedPipe | WriteMessages после Close | Упорядочить шатдаун: сначала дозапись, потом Close |
| context.DeadlineExceeded | Дедлайн контекста истёк посреди отправки | Исход неизвестен: повторить с идемпотентностью |
| kafka.LeaderNotAvailable и соседи | Брокерские коды на переездах лидера и таймаутах | Writer ретраит сам; при исчерпании ошибки окажутся в WriteErrors |

### Соберите обработчик с политикой повторов

Допишите лимит попыток, нижнюю границу бэкоффа и тип ошибки пачки.

```go
writer := kafka.NewWriter(kafka.WriterConfig{
        Brokers:         []string{"localhost:9092"},
        Topic:           "orders",
        ___:             5,
        ___:             200 * time.Millisecond,
})

err := writer.WriteMessages(ctx, msgs...)
var werr kafka.___
if errors.As(err, &werr) {
        for i := range werr {
                if werr[i] != nil {
                        log.Printf("msg %d: %v", i, werr[i])
                }
        }
}
```

Подсказка: Число попыток — MaxAttempts, нижняя граница ожидания — WriteBackoffMin, а пачка ошибок называется WriteErrors.

Правильные ответы в пропусках: `MaxAttempts`, `WriteBackoffMin`, `WriteErrors`

### Соберите позиционный разбор WriteErrors

Расставьте строки обработчика: отправка, распознавание WriteErrors, цикл по позициям с пропуском доставленных и логом виновников.

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

1. `err := writer.WriteMessages(ctx, msgs...)`
2. `var werr kafka.WriteErrors`
3. `if !errors.As(err, &werr) { return err }`
4. `for i := range werr {`
5. `if werr[i] == nil { continue }`
6. `log.Printf("msg %d failed: %v", i, werr[i])`
7. `}`
8. `return nil`

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

- **MaxAttempts** — Лимит попыток доставки пачки, дефолт 10. После исчерпания ошибка возвращается вызывающему.
- **WriteBackoffMin/Max** — Границы ожидания между попытками, дефолты 100 мс и 1 секунда.
- **kafka.WriteErrors** — Срез ошибок по числу сообщений пачки: позиция совпадает с позицией сообщения, nil — доставлено.
- **MessageTooLargeError** — Сообщение крупнее BatchBytes: вызов прерывается до отправки; в ошибке поля Message и Remaining.
- **io.ErrClosedPipe** — Ответ WriteMessages после Close: шатдаун упорядочивается — дозапись, потом закрытие.
- **Stats().Retries** — Счётчик фактических повторов Writer: растёт на морганиях сети и переездах лидеров.

Продюсер теперь умеет проигрывать: политика повторов описана полями конфига, ошибки разобраны по позициям, непосланное имеет план. Дальше по курсу: [четвёртый урок про первый Writer](/courses/kafka-go/lessons/04-pervyj-producer/) покажет фон, из которого вырос этот обработчик; [десятый урок про батчи](/courses/kafka-go/lessons/10-producer-batchi/) объяснит, как Async меняет путь ошибок; [пятнадцатый урок про durability](/courses/kafka-go/lessons/15-durability/) свяжет RequiredAcks с тем, сколько брокеров должны подтвердить запись до возврата ошибки; а [девятнадцатый урок про ошибки консьюмера](/courses/kafka-go/lessons/19-oshibki-consumer/) покажет зеркальную сторону — что делать с сообщениями, которые не читаются. Код урока запускается локально против брокера в Docker: уроните его и посмотрите, как ошибки становятся данными.

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

- Writer ретраит доставку сам: до MaxAttempts попыток с бэкоффом от 100 миллисекунд до секунды.
- WriteErrors возвращает по одной ошибке на позицию пачки: nil-элемент — доставленное сообщение.
- MessageTooLargeError приходит до отправки: поля Message и Remaining позволяют продолжить без пересборки.
- Таймауты пути — ReadTimeout и WriteTimeout по 10 секунд, внешний бюджет — дедлайн контекста.
- После Close WriteMessages возвращает io.ErrClosedPipe: закрытие — последний шаг шатдауна.
- Отмена контекста: исход пачки неизвестен, повтор возможен — значит, возможны и дубликаты.
- Непосланное — всегда решение: буфер, транзакционный outbox или дроп с метрикой, но не молчание.

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

Против брокера из третьего урока отправьте пачку из ста событий, затем docker stop на брокер и повторную пачку: разберите WriteErrors по позициям и запишите, сколько сообщений доехало до остановки. Выставьте MaxAttempts: 2 и WriteBackoffMin: 50 миллисекунд, сравните время возврата ошибки с дефолтами. Добавьте обработку MessageTooLargeError с отправкой Remaining и подтвердите логом, что хвост доехал. Финал: заведите счётчик недоставленных сообщений и напишите для каждого топика проекта одно предложение — буфер, outbox или дроп, и почему.

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

### Почему Writer молча переживает короткий сбой брокера?

Потому что ретраи встроены: Writer повторяет доставку до MaxAttempts попыток с бэкоффом от WriteBackoffMin до WriteBackoffMax, и моргание в несколько сотен миллисекунд перекрывается без вашего участия. Признак этой работы — счётчик Retries в writer.Stats(). Если же попытки исчерпаны, ошибка возвращается из WriteMessages и дальше её разбираете вы.

### Что вернёт WriteMessages при Async: true?

Ошибок отправки вызывающий не увидит вовсе: асинхронный Writer не возвращает их из WriteMessages, они оседают в Stats().Errors, а о судьбе конкретной пачки отчитывается только Completion из десятого урока. Косвенный признак потери — счётчик Errors в Stats: если вы ловите потерю постфактум по аналитике, а не по Completion, ваш мониторинг опаздывает на очередь. Обработка WriteErrors — прерогатива синхронного режима: для критичных потоков Async не берут, и этот урок — ещё один аргумент почему.

### Как понять, какую ошибку из WriteErrors стоит повторять?

Брокерские коды делятся на временные и конфигурационные: LeaderNotAvailable или RequestTimedOut — временные, UnknownTopicOrPartition — ошибка настройки, повторять бессмысленно. Отдельный случай — NotEnoughReplicas: по классификации протокола он временный, но говорит о сужении ISR ниже минимума, то есть о болезни кластера, а не сети; его рост — повод смотреть нодам и репликам, о чём говорит пятнадцатый урок. Практическая развилка проще: Writer уже отретраил временные до MaxAttempts, значит, всё, что доехало до вас в WriteErrors, автоматикой не лечится. Дальше — пауза с повтором через буфер или outbox, либо осознанный дроп с метрикой.
