fix(web): a client that hangs up is logged at INFO and not answered; the 400 follows the marker, not io.ErrUnexpectedEOF; hasNote (third review)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 17:36:42 +02:00
co-authored by Claude Sonnet 5.5
parent 632dbeba0e
commit 6d044ba403
+31 -2
View File
@@ -95,6 +95,9 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
} }
if err != nil { if err != nil {
var tooBig *http.MaxBytesError var tooBig *http.MaxBytesError
if a.abandoned(r) {
return
}
if !errors.As(err, &tooBig) { if !errors.As(err, &tooBig) {
// A malformed multipart body (a header with a control byte, a truncated // A malformed multipart body (a header with a control byte, a truncated
// part) is the sender's to fix: a 400, not a server error, and the error // part) is the sender's to fix: a 400, not a server error, and the error
@@ -130,10 +133,24 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.") a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
} }
// abandoned reports, and logs at INFO, that the client has already gone: its request
// context is cancelled, so there is nobody to answer and rendering a page would run its
// database read on a dead context and log a false ERROR. A hang-up mid-upload is routine.
func (a *App) abandoned(r *http.Request) bool {
if r.Context().Err() == nil {
return false
}
a.logger().Info("upload abandoned", "reason", "client_gone")
return true
}
// uploadFailure maps an Accept (or multipart) error to a response: a validation // uploadFailure maps an Accept (or multipart) error to a response: a validation
// rejection is the user's to fix (422, or 413 for size) and carries its own // rejection is the user's to fix (422, or 413 for size) and carries its own
// message; anything else is ours. // message; anything else is ours.
func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) { func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) {
if a.abandoned(r) {
return
}
var rej *uploads.RejectError var rej *uploads.RejectError
var tooBig *http.MaxBytesError var tooBig *http.MaxBytesError
switch { switch {
@@ -152,8 +169,9 @@ func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID strin
a.logger().Info("upload rejected", "reason", string(uploads.ReasonTooLarge)) a.logger().Info("upload rejected", "reason", string(uploads.ReasonTooLarge))
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge, a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
"This file is larger than the upload limit.") "This file is larger than the upload limit.")
case errors.Is(err, io.ErrUnexpectedEOF): case errors.Is(err, uploads.ErrClientRead):
// The sender hung up mid-file: theirs to retry, not a server fault. // An error reading the CLIENT's body: theirs to retry, not a server fault. Only that
// marker counts: io.ErrUnexpectedEOF alone is also what a database drop looks like.
a.logger().Info("upload rejected", "reason", "truncated") a.logger().Info("upload rejected", "reason", "truncated")
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "The upload was cut off before it finished. Please try again.") a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "The upload was cut off before it finished. Please try again.")
default: default:
@@ -219,6 +237,17 @@ func titleFromFilename(name string) string {
return t return t
} }
// hasNote reports whether any phase carries a note. A note is the only warning for a
// recording that is shorter than its file claimed, so a record that has one is shown open.
func hasNote(steps []domain.ProgressStep) bool {
for _, s := range steps {
if s.Note != "" {
return true
}
}
return false
}
// uuidShape is the form of an upload id. Anything else cannot be one, so it is a 404. // uuidShape is the form of an upload id. Anything else cannot be one, so it is a 404.
var uuidShape = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) var uuidShape = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)