From cded16d7f50b655f71c0c2ac43e78e326c8a2150 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sun, 1 Feb 2026 14:36:32 -0600 Subject: [PATCH] fix(api): retry item creation on duplicate part number collision The sequence counter (sequences_by_name table) can get out of sync with the items table if items were seeded/imported directly or if a previous create failed after incrementing the sequence but before the insert committed. This causes Generate() to return a part number that already exists, hitting the unique constraint on items.part_number. Add a retry loop (up to 5 attempts) in HandleCreateItem that detects PostgreSQL unique violation errors (SQLSTATE 23505) via pgconn.PgError and retries with the next sequence value. Non-duplicate errors still fail immediately. If all retries are exhausted, returns 409 Conflict instead of 500. --- internal/api/handlers.go | 71 +++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 22 deletions(-) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 6347a86..d09f826 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "errors" "fmt" "net/http" "os" @@ -12,6 +13,7 @@ import ( "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/kindredsystems/silo/internal/auth" "github.com/kindredsystems/silo/internal/config" "github.com/kindredsystems/silo/internal/db" @@ -358,13 +360,6 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { }, } - partNumber, err := s.partgen.Generate(ctx, input) - if err != nil { - s.logger.Error().Err(err).Msg("failed to generate part number") - writeError(w, http.StatusBadRequest, "generation_failed", err.Error()) - return - } - // Determine item type from category itemType := "part" if len(req.Category) > 0 { @@ -376,32 +371,64 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { } } - // Create item - item := &db.Item{ - PartNumber: partNumber, - ItemType: itemType, - Description: req.Description, - SourcingType: req.SourcingType, - SourcingLink: req.SourcingLink, - LongDescription: req.LongDescription, - StandardCost: req.StandardCost, - } - if user := auth.UserFromContext(ctx); user != nil { - item.CreatedBy = &user.Username - } - properties := req.Properties if properties == nil { properties = make(map[string]any) } properties["category"] = req.Category - if err := s.items.Create(ctx, item, properties); err != nil { + // Retry loop: if the generated part number collides with an existing + // item (sequence counter out of sync), generate a new one and retry. + const maxRetries = 5 + var item *db.Item + for attempt := 0; attempt < maxRetries; attempt++ { + partNumber, err := s.partgen.Generate(ctx, input) + if err != nil { + s.logger.Error().Err(err).Msg("failed to generate part number") + writeError(w, http.StatusBadRequest, "generation_failed", err.Error()) + return + } + + item = &db.Item{ + PartNumber: partNumber, + ItemType: itemType, + Description: req.Description, + SourcingType: req.SourcingType, + SourcingLink: req.SourcingLink, + LongDescription: req.LongDescription, + StandardCost: req.StandardCost, + } + if user := auth.UserFromContext(ctx); user != nil { + item.CreatedBy = &user.Username + } + + err = s.items.Create(ctx, item, properties) + if err == nil { + break // success + } + + // Check if this is a duplicate key error — retry with next sequence + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + s.logger.Warn(). + Str("part_number", partNumber). + Int("attempt", attempt+1). + Msg("duplicate part number, retrying with next sequence value") + continue + } + + // Non-duplicate error, fail immediately s.logger.Error().Err(err).Msg("failed to create item") writeError(w, http.StatusInternalServerError, "create_failed", err.Error()) return } + if item == nil || item.ID == "" { + s.logger.Error().Int("retries", maxRetries).Msg("exhausted retries for part number generation") + writeError(w, http.StatusConflict, "duplicate_part_number", "Could not generate a unique part number after multiple attempts") + return + } + // Tag item with projects if provided if len(req.Projects) > 0 { for _, projectCode := range req.Projects {