Address seed-packet review: 413 mapping, deadline, rollback, dedup
Build image / build-and-push (push) Successful in 6s
Build image / build-and-push (push) Successful in 6s
Gadfly findings on #94, the real ones: - scanSeedPacket extends only the READ deadline; a slow upload + a live vision call runs past the server's absolute 30s WriteTimeout and the successful response is silently dropped (the #78 failure mode). Extend the write deadline too (scanWriteTimeout). - An oversized upload tripping MaxBytesReader was mapped to 400; it's 413. Detect *http.MaxBytesError and report IMAGE_TOO_LARGE. - Split imagenorm error mapping: ErrTooLarge->413, ErrUnsupported->400, genuine read/encode faults (and a failed file.Open)->500, not 400. - CreateFromPacket discarded the plant it created when the lot then failed, contradicting its own doc. Roll the new plant back instead so the confirm is all-or-nothing (a fresh plant has no lots/plantings, so the delete is safe; log-and-continue on cleanup failure). - Dedup: packetLotRequest and seedLotCreateRequest shared every lot field. Extract a seedLotFields base both use. validCategory now reuses plantCategories. EffectiveConfig resolves agent+vision from one settings-row read instead of two. - capabilities swallowed an EffectiveVision error silently; log it. - vision test hand-copied Extract's body (drift risk). Split generate() out of Extract so the hermetic test drives the real request builder. Tests: rollback-on-lot-failure (service), oversized->413 (api). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
+35
-36
@@ -29,21 +29,40 @@ const scanUploadLimit = 30 << 20
|
||||
// ResponseController mechanism the SSE path uses for writes (#78).
|
||||
const scanReadTimeout = 60 * time.Second
|
||||
|
||||
// scanWriteTimeout extends the write deadline for the same reason. The server's
|
||||
// absolute WriteTimeout (30s) is measured from the start of the request, but this
|
||||
// handler's response can't be written until AFTER a slow upload AND a live vision
|
||||
// call — together easily past 30s. Without this, a successful extraction's
|
||||
// response is silently dropped: the exact failure mode #78 fixed for SSE.
|
||||
const scanWriteTimeout = 120 * time.Second
|
||||
|
||||
// scanSeedPacket reads an uploaded packet photo and returns a proposal.
|
||||
func (h *handlers) scanSeedPacket(c *gin.Context) {
|
||||
// Extend the read deadline for the (potentially large, potentially slow)
|
||||
// upload. Best-effort: if the writer doesn't support it, the default applies.
|
||||
_ = http.NewResponseController(c.Writer).SetReadDeadline(time.Now().Add(scanReadTimeout))
|
||||
// Extend both deadlines for the (potentially large, potentially slow) upload
|
||||
// and the live vision call that follows. Best-effort: if the writer doesn't
|
||||
// support it, the server defaults apply.
|
||||
rc := http.NewResponseController(c.Writer)
|
||||
_ = rc.SetReadDeadline(time.Now().Add(scanReadTimeout))
|
||||
_ = rc.SetWriteDeadline(time.Now().Add(scanWriteTimeout))
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit)
|
||||
file, err := c.FormFile("image")
|
||||
if err != nil {
|
||||
// A body over scanUploadLimit trips MaxBytesReader — that's 413, not a
|
||||
// malformed request. Everything else here is a genuinely missing/garbled
|
||||
// multipart field.
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) {
|
||||
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
|
||||
return
|
||||
}
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "attach an image as the \"image\" field")
|
||||
return
|
||||
}
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "could not read the uploaded image")
|
||||
// Opening the parsed upload failed on our side, not the client's.
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not read the uploaded image")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -51,16 +70,19 @@ func (h *handlers) scanSeedPacket(c *gin.Context) {
|
||||
// Normalize to JPEG (decodes HEIC/webp/png/jpeg, downscales, re-encodes) so
|
||||
// everything downstream — including the vision model — only sees a format it
|
||||
// can read. This is where an iPhone HEIC becomes usable.
|
||||
jpeg, format, err := imagenorm.Normalize(f, imagenorm.Options{})
|
||||
jpeg, _, err := imagenorm.Normalize(f, imagenorm.Options{})
|
||||
if err != nil {
|
||||
if errors.Is(err, imagenorm.ErrTooLarge) {
|
||||
switch {
|
||||
case errors.Is(err, imagenorm.ErrTooLarge):
|
||||
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
|
||||
return
|
||||
case errors.Is(err, imagenorm.ErrUnsupported):
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "that doesn't look like an image we can read (JPEG, PNG, HEIC or WebP)")
|
||||
default:
|
||||
// A read or re-encode fault is ours, not bad input.
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not process the uploaded image")
|
||||
}
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "that doesn't look like an image we can read (JPEG, PNG, HEIC or WebP)")
|
||||
return
|
||||
}
|
||||
_ = format // available for logging if wanted
|
||||
|
||||
prop, err := h.svc.ExtractSeedPacket(c.Request.Context(), mustActor(c).ID, jpeg)
|
||||
if err != nil {
|
||||
@@ -77,37 +99,14 @@ func (h *handlers) scanSeedPacket(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, prop)
|
||||
}
|
||||
|
||||
// packetLotRequest is the lot half of a confirm. It's seedLotCreateRequest
|
||||
// WITHOUT plantId — the plant comes from the confirm's plantId/newPlant choice,
|
||||
// not the lot body, and reusing seedLotCreateRequest would wrongly require one.
|
||||
type packetLotRequest struct {
|
||||
Vendor string `json:"vendor"`
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
SKU string `json:"sku"`
|
||||
LotCode string `json:"lotCode"`
|
||||
PurchasedAt *string `json:"purchasedAt"`
|
||||
PackedForYear *int `json:"packedForYear"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unit string `json:"unit" binding:"required"`
|
||||
CostCents *int `json:"costCents"`
|
||||
GerminationPct *float64 `json:"germinationPct"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
func (r packetLotRequest) toInput() service.SeedLotInput {
|
||||
return service.SeedLotInput{
|
||||
Vendor: r.Vendor, SourceURL: r.SourceURL, SKU: r.SKU, LotCode: r.LotCode,
|
||||
PurchasedAt: r.PurchasedAt, PackedForYear: r.PackedForYear, Quantity: r.Quantity,
|
||||
Unit: r.Unit, CostCents: r.CostCents, GerminationPct: r.GerminationPct, Notes: r.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
// fromPacketRequest confirms a proposal: exactly one of plantId (attach to an
|
||||
// existing plant) or newPlant (create a variety), plus the lot to record.
|
||||
// existing plant) or newPlant (create a variety), plus the lot to record. The lot
|
||||
// is seedLotFields — the create body's lot half WITHOUT plantId, since the plant
|
||||
// comes from the plantId/newPlant choice, not the lot body.
|
||||
type fromPacketRequest struct {
|
||||
PlantID *int64 `json:"plantId"`
|
||||
NewPlant *plantCreateRequest `json:"newPlant"`
|
||||
Lot packetLotRequest `json:"lot"`
|
||||
Lot seedLotFields `json:"lot"`
|
||||
}
|
||||
|
||||
// createFromPacket turns a confirmed proposal into a plant + lot.
|
||||
|
||||
Reference in New Issue
Block a user