Compare commits

...
Author SHA1 Message Date
Chris Lu 578fed7d30 extend cache-not-ready handling to filer HTTP path
Mirror the s3api change for the native filer HTTP handlers. When the
filer GET hits a remote-only object whose cache fill hasn't completed,
return 503 Service Unavailable with Retry-After: 5 instead of 500
Internal Error, and treat client disconnects as silent cancellations
rather than logging them as errors.

Adds an ErrCacheNotReady sentinel and a small helper used at the
prepareWriteFn-error sites in ProcessRangeRequest, so the same
classification (cancel / not-ready / other) applies to plain GETs,
single-range, and multi-range requests.
2026-04-27 01:34:40 -07:00
Chris Lu 6b701a94c4 simplify comments 2026-04-27 01:24:15 -07:00
Chris Lu 18b51253da treat client cancel as cancellation, not 503
If r.Context() is already canceled when the cache attempt returns no
chunks, the cache failure is almost certainly a side-effect of the
client disconnecting, not real backpressure. Surface the context error
so GetObjectHandler logs at V(3) and skips writing a response, instead
of synthesizing a 503 that nobody will read.

Addresses Gemini review feedback on #9233.
2026-04-27 01:16:41 -07:00
Chris Lu 280f620b55 fix(s3api): return 503 with Retry-After when remote object not cached yet
When a GET hits a remote-only object whose cache fill timed out or was
canceled, the handler returned 500 InternalError. SDK clients treat 500
as a server bug and surface it as a fatal error (boto3
S3DownloadFailedError), even though the cache is often still filling in
the background and the next request would succeed.

Return 503 ServiceUnavailable with Retry-After: 5 instead, matching
AWS S3's "try again later" semantics. AWS SDKs already classify 503 as
retryable and apply exponential backoff transparently, so clients
recover without changes.

Refs https://github.com/seaweedfs/seaweedfs/discussions/9174
2026-04-27 01:11:04 -07:00
3 changed files with 43 additions and 15 deletions
+9 -4
View File
@@ -1000,10 +1000,15 @@ func (s3a *S3ApiServer) streamFromVolumeServers(w http.ResponseWriter, r *http.R
entry = cachedEntry
glog.V(1).Infof("streamFromVolumeServers: successfully cached remote object, got %d chunks", len(chunks))
} else {
// Caching failed - return error to client
glog.Errorf("streamFromVolumeServers: failed to cache remote object for streaming")
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return newStreamErrorWithResponse(fmt.Errorf("failed to cache remote object for streaming"))
// Client disconnected: report cancellation, not 503.
if ctxErr := r.Context().Err(); ctxErr != nil {
return ctxErr
}
// Cache still filling: 503 + Retry-After so SDKs back off and retry.
glog.V(1).Infof("streamFromVolumeServers: remote object %s/%s not cached yet, returning 503 for retry", bucket, object)
w.Header().Set("Retry-After", "5")
s3err.WriteErrorResponse(w, r, s3err.ErrServiceUnavailable)
return newStreamErrorWithResponse(fmt.Errorf("remote object not cached yet"))
}
} else if totalSize > 0 && len(entry.Content) == 0 {
// Not a remote entry but has size without content - this is a data integrity issue
+27 -9
View File
@@ -3,6 +3,7 @@ package weed_server
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -39,6 +40,28 @@ var writePool = sync.Pool{New: func() interface{} {
},
}
// ErrCacheNotReady signals that a remote-only object's local cache is still
// filling. Callers should map it to 503 + Retry-After so SDKs back off and retry.
var ErrCacheNotReady = errors.New("remote object not cached yet")
// writePrepareWriteFnErr writes an HTTP response for an error from
// prepareWriteFn, before any 2xx headers have been written. Client cancels are
// silent; ErrCacheNotReady becomes 503 + Retry-After; everything else is 500.
func writePrepareWriteFnErr(w http.ResponseWriter, err error) {
w.Header().Del("Content-Length")
switch {
case errors.Is(err, context.Canceled):
glog.V(3).Infof("ProcessRangeRequest: client disconnected: %v", err)
case errors.Is(err, ErrCacheNotReady):
glog.V(1).Infof("ProcessRangeRequest: cache not ready, returning 503: %v", err)
w.Header().Set("Retry-After", "5")
http.Error(w, err.Error(), http.StatusServiceUnavailable)
default:
glog.Errorf("ProcessRangeRequest: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func init() {
serverStats = stats.NewServerStats()
go serverStats.Start()
@@ -290,9 +313,7 @@ func ProcessRangeRequest(r *http.Request, w http.ResponseWriter, totalSize int64
w.Header().Set("Content-Length", strconv.FormatInt(totalSize, 10))
writeFn, err := prepareWriteFn(0, totalSize)
if err != nil {
glog.Errorf("ProcessRangeRequest: %v", err)
w.Header().Del("Content-Length")
http.Error(w, err.Error(), http.StatusInternalServerError)
writePrepareWriteFnErr(w, err)
return fmt.Errorf("ProcessRangeRequest: %w", err)
}
if err = writeFn(bufferedWriter); err != nil {
@@ -340,9 +361,7 @@ func ProcessRangeRequest(r *http.Request, w http.ResponseWriter, totalSize int64
writeFn, err := prepareWriteFn(ra.start, ra.length)
if err != nil {
glog.Errorf("ProcessRangeRequest range[0]: %+v err: %v", w.Header(), err)
w.Header().Del("Content-Length")
http.Error(w, err.Error(), http.StatusInternalServerError)
writePrepareWriteFnErr(w, err)
return fmt.Errorf("ProcessRangeRequest: %w", err)
}
w.WriteHeader(http.StatusPartialContent)
@@ -365,9 +384,8 @@ func ProcessRangeRequest(r *http.Request, w http.ResponseWriter, totalSize int64
}
writeFn, err := prepareWriteFn(ra.start, ra.length)
if err != nil {
glog.Errorf("ProcessRangeRequest range[%d] err: %v", i, err)
http.Error(w, "Internal Error", http.StatusInternalServerError)
return fmt.Errorf("ProcessRangeRequest range[%d] err: %v", i, err)
writePrepareWriteFnErr(w, err)
return fmt.Errorf("ProcessRangeRequest range[%d]: %w", i, err)
}
writeFnByRange[i] = writeFn
}
+7 -2
View File
@@ -211,8 +211,13 @@ func (fs *FilerServer) GetOrHeadHandler(w http.ResponseWriter, r *http.Request)
Name: name,
}); err != nil {
stats.FilerHandlerCounter.WithLabelValues(stats.ErrorReadCache).Inc()
glog.ErrorfCtx(ctx, "CacheRemoteObjectToLocalCluster %s: %v", entry.FullPath, err)
return nil, fmt.Errorf("cache %s: %v", entry.FullPath, err)
// Client disconnected: surface ctx error so caller stays silent.
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
// Cache still filling: tag with sentinel so caller maps to 503 + Retry-After.
glog.WarningfCtx(ctx, "CacheRemoteObjectToLocalCluster %s: %v", entry.FullPath, err)
return nil, fmt.Errorf("cache %s: %w", entry.FullPath, ErrCacheNotReady)
} else {
chunks = resp.Entry.GetChunks()
}