diff --git a/internal/uploads/intake.go b/internal/uploads/intake.go index d7afd85..8f5ce13 100644 --- a/internal/uploads/intake.go +++ b/internal/uploads/intake.go @@ -58,6 +58,27 @@ func NewIntake(s Store, p Prober, sub Submitter, l Limits, tmpDir string) *Intak return &Intake{store: s, prober: p, sub: sub, limits: l, tmpDir: tmpDir} } +// ErrClientRead marks an error that came from READING the client's body: a connection that +// dropped, a body cut short. It is the sender's to retry, unlike a spool, probe or database +// fault, which is ours. It is a marker on the error, not a sentinel that others may wrap: +// io.ErrUnexpectedEOF alone is also what a database drop looks like. +var ErrClientRead = errors.New("uploads: client body read failed") + +// clientReader remembers the last non-EOF error its source returned, so Accept can tell +// a failure reading the client from a failure writing the spool file. +type clientReader struct { + r io.Reader + err error +} + +func (c *clientReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + if err != nil && !errors.Is(err, io.EOF) { + c.err = err + } + return n, err +} + // busyMessage is what a user is told when the queue is full. const busyMessage = "Too many recordings are waiting to be transcribed. Please try again in a few minutes." @@ -86,11 +107,15 @@ func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reade }() // Read one byte past the cap so "exactly at the limit" and "over" are told apart. - n, err := io.Copy(f, io.LimitReader(src, in.limits.MaxBytes+1)) + cr := &clientReader{r: io.LimitReader(src, in.limits.MaxBytes+1)} + n, err := io.Copy(f, cr) if cerr := f.Close(); err == nil { err = cerr } if err != nil { + if cr.err != nil && errors.Is(err, cr.err) { + return "", fmt.Errorf("%w: %w", ErrClientRead, err) + } return "", fmt.Errorf("uploads: receive: %w", err) } if n > in.limits.MaxBytes {