> Как обрабатывать ошибки при отмене брони на складе (Go)

Уровень: senior · Роль: backend · Категория: Технические вопросы

Компании: sferaplatform.ru

Стек: Go

> Пример ответа

Короткий ответ

Обработка ошибок при отмене брони на складе строится вокруг трёх ключевых моментов: идемпотентность (повторный вызов не должен ломать состояние), атомарность (обновление брони и возврат товара в остаток - в одной транзакции) и явное различие между бизнес-ошибками (бронь не найдена, уже отменена) и техническими сбоями (таймаут БД, сеть). В Go это реализуется через typed errors, sentinel errors и middleware для маппинга ошибок в HTTP-статусы. Отмену делаем идемпотентной: повторная отмена возвращает успех, но не меняет состояние.

Подробное объяснение

Отмена брони - операция с побочными эффектами: нужно пометить бронь как cancelled и вернуть зарезервированное количество товара в доступный остаток. Основные сценарии ошибок:

  1. Бронь не найдена - бизнес-ошибка, клиент получил неверный идентификатор.
  2. Бронь уже отменена - идемпотентный случай, не ошибка, а нормальное состояние.
  3. Бронь в статусе completed - нельзя отменить завершённую операцию, это нарушение инварианта.
  4. Конфликт остатков - при возврате товара остаток может быть изменён параллельной операцией (например, списание). Нужна блокировка строки или optimistic locking.
  5. Технические сбои - недоступность БД, таймаут, сетевые ошибки. Их нельзя маппить в 4xx, только 5xx.

Ключевой паттерн - транзакция с явной блокировкой:

  • SELECT ... FOR UPDATE по строке брони и строке товара.
  • Проверка статуса внутри транзакции.
  • Обновление статуса и остатка.
  • Commit или rollback.

Для идемпотентности используем уникальный ключ операции (например, operation_id в таблице отмен) или проверку статуса: если status = cancelled, возвращаем успех без изменений.

Ошибки в Go: определяем sentinel errors (ErrReservationNotFound, ErrReservationAlreadyCancelled) и оборачиваем через fmt.Errorf("...: %w", err). Для HTTP-слоя - middleware, который по errors.Is маппит в статусы: 404, 409, 422, 500.

На практике

  • Всегда начинай с валидации входных данных (id брони, id товара, количество).
  • Используй context.Context с таймаутом для запросов к БД.
  • Для конкурентного доступа к остаткам - SELECT FOR UPDATE или версионирование строки (поле version, проверка при UPDATE).
  • Логируй ошибки с контекстом: id брони, trace id, длительность операции.
  • Не возвращай внутренние детали ошибок клиенту - только код и сообщение.
  • Для распределённых систем (если склад и заказы в разных сервисах) - используй outbox pattern или сагу, чтобы отмена брони и возврат остатка были согласованы.

Пример кода

GO
var (
ErrReservationNotFound = errors.New("reservation not found")
ErrReservationAlreadyClosed = errors.New("reservation already closed")
ErrInsufficientStock = errors.New("insufficient stock")
)
type Reservation struct {
ID int64
Status string // "active", "cancelled", "completed"
}
type StockItem struct {
ID int64
Quantity int
Version int
}
func CancelReservation(ctx context.Context, db *sql.DB, reservationID int64) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
// Блокируем строку брони
var res Reservation
err = tx.QueryRowContext(ctx,
`SELECT id, status FROM reservations WHERE id = $1 FOR UPDATE`,
reservationID,
).Scan(&res.ID, &res.Status)
if errors.Is(err, sql.ErrNoRows) {
return ErrReservationNotFound
}
if err != nil {
return fmt.Errorf("select reservation: %w", err)
}
// Идемпотентность
if res.Status == "cancelled" {
return nil
}
if res.Status == "completed" {
return ErrReservationAlreadyClosed
}
// Блокируем строку товара (нужен item_id из брони)
var item StockItem
err = tx.QueryRowContext(ctx,
`SELECT id, quantity, version FROM stock_items WHERE id = $1 FOR UPDATE`,
res.ID, // упрощение: item_id = reservation_id
).Scan(&item.ID, &item.Quantity, &item.Version)
if err != nil {
return fmt.Errorf("select stock item: %w", err)
}
// Возвращаем количество (в примере - фиксированное 1)
newQuantity := item.Quantity + 1
_, err = tx.ExecContext(ctx,
`UPDATE stock_items SET quantity = $1, version = version + 1 WHERE id = $2 AND version = $3`,
newQuantity, item.ID, item.Version,
)
if err != nil {
return fmt.Errorf("update stock: %w", err)
}
_, err = tx.ExecContext(ctx,
`UPDATE reservations SET status = 'cancelled' WHERE id = $1`,
reservationID,
)
if err != nil {
return fmt.Errorf("update reservation: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}

Как отвечать на собеседовании

Начни с классификации ошибок: бизнес-ошибки vs технические. Затем опиши транзакционность и идемпотентность как два главных принципа. Приведи конкретный пример с SELECT FOR UPDATE и sentinel errors. Упомяни, как обрабатываешь повторные вызовы (повторная отмена - успех). Если спросят про распределённый случай - расскажи про сагу или outbox. Не углубляйся в детали реализации фреймворка, говори про паттерны.

Что проверяет интервьюер

  • Понимание разницы между бизнес- и техническими ошибками.
  • Умение проектировать идемпотентные операции.
  • Знание транзакций и конкурентного доступа в SQL.
  • Навык работы с ошибками в Go: wrapping, errors.Is, sentinel errors.
  • Умение маппить ошибки в HTTP-статусы.
  • Способность рассуждать о консистентности данных.

Типичные ошибки

  • Возвращать 500 на бизнес-ошибки (бронь не найдена).
  • Не делать операцию идемпотентной - повторный вызов падает с ошибкой.
  • Обновлять остаток и статус брони без транзакции - рассинхрон.
  • Игнорировать гонки: два запроса одновременно отменяют бронь, оба возвращают товар.
  • Возвращать клиенту внутренние детали ошибки (стек, SQL-запрос).
  • Не использовать context с таймаутом - зависание при недоступной БД.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью