Files
pansy/internal/api/seed_packet.go
T
steveandClaude Fable 5 2a903f6428
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 10m9s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m10s
Make request deadline extensions reach the socket behind the logging middleware
Long agent turns were cut at exactly 30s on the live instance with "The
connection dropped partway through." — the #78 failure, which its tests
said was fixed. The tests host openEventStream on a bare gin.New(); in
production, slog-gin replaces c.Writer with a wrapper that embeds the
gin.ResponseWriter interface, which has no Unwrap, so the ResponseController
built from the handler's writer can't reach the connection and every
SetWriteDeadline returns ErrNotSupported. The stream fell back to the
server's absolute WriteTimeout; the first write past it failed, cancelled
the request context, and closed the socket under the client mid-frame.
The scan upload's read/write extensions failed the same way, with the
errors discarded.

captureController now runs first on the engine and stashes a controller
built before anything wraps the writer; openEventStream and scanSeedPacket
take it from responseController(c). The regression tests run the stream
through New() — the real stack, in the real order — and check from the
client side; the scan path logs once instead of swallowing the error.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:56:21 -04:00

142 lines
5.8 KiB
Go

package api
import (
"errors"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/imagenorm"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// Seed-packet capture (#81). Two steps, deliberately separate:
// POST /seed-lots/scan multipart image → a proposal (reads only)
// POST /seed-lots/from-packet confirmed proposal → a plant + lot
// The scan never writes; creation happens only from an explicit confirm, so a
// misread can't add anything to the catalog on its own.
// scanUploadLimit bounds the multipart body. imagenorm caps the decoded image at
// 25 MiB; this is a little over that for the multipart envelope. A phone photo is
// a few MB, so this is generous.
const scanUploadLimit = 30 << 20
// scanReadTimeout is how long we allow the image upload to take. The server's
// default ReadTimeout (15s) is fine for JSON but tight for a multi-megabyte photo
// on a slow phone connection, so this endpoint extends it — the same
// 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 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 — but say so, once, because this
// failed silently behind the logging middleware for as long as the errors
// were discarded (see deadlines.go). The second call can only fail the same
// way as the first, so it isn't reported twice.
rc := responseController(c)
if err := rc.SetReadDeadline(time.Now().Add(scanReadTimeout)); err != nil {
slog.Error("api: scan deadlines unavailable; slow uploads will be cut at the server ReadTimeout", "error", err)
}
_ = 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 {
// 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()
// 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, _, err := imagenorm.Normalize(f, imagenorm.Options{})
if err != nil {
switch {
case errors.Is(err, imagenorm.ErrTooLarge):
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
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")
}
return
}
prop, err := h.svc.ExtractSeedPacket(c.Request.Context(), mustActor(c).ID, jpeg)
if err != nil {
// A missing vision model surfaces as ErrInvalidInput from the service; give
// it a clearer message than the generic 400, since the UI shouldn't have
// offered the button at all in that case.
if errors.Is(err, domain.ErrInvalidInput) {
writeAPIError(c, http.StatusServiceUnavailable, "VISION_DISABLED", "packet scanning isn't set up on this instance")
return
}
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, prop)
}
// fromPacketRequest confirms a proposal: exactly one of plantId (attach to an
// 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 seedLotFields `json:"lot"`
}
// createFromPacket turns a confirmed proposal into a plant + lot.
func (h *handlers) createFromPacket(c *gin.Context) {
var req fromPacketRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a lot and exactly one of plantId or newPlant are required")
return
}
confirm := service.PacketConfirm{
PlantID: req.PlantID,
Lot: req.Lot.toInput(), // no plantId in the lot body; the service attributes it
}
if req.NewPlant != nil {
in := req.NewPlant.toInput()
confirm.NewPlant = &in
}
res, err := h.svc.CreateFromPacket(c.Request.Context(), mustActor(c).ID, confirm)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusCreated, res)
}