···660660 next.ServeHTTP(w, r)
661661 })
662662}
663663+664664+// retryAfterResponseWriter wraps http.ResponseWriter and, on the first
665665+// WriteHeader call, injects a Retry-After header if the status is 429 and
666666+// a retry-after duration was recorded in the request context.
667667+type retryAfterResponseWriter struct {
668668+ http.ResponseWriter
669669+ carrier *storage.RetryAfterCarrier
670670+ wroteHeader bool
671671+}
672672+673673+func (w *retryAfterResponseWriter) WriteHeader(code int) {
674674+ if !w.wroteHeader {
675675+ w.wroteHeader = true
676676+ if code == http.StatusTooManyRequests {
677677+ if d := w.carrier.Duration(); d > 0 {
678678+ // Round up to whole seconds; minimum of 1 to avoid 0-second hints.
679679+ secs := int64(d.Seconds())
680680+ if d%time.Second != 0 {
681681+ secs++
682682+ }
683683+ if secs < 1 {
684684+ secs = 1
685685+ }
686686+ w.Header().Set("Retry-After", fmt.Sprintf("%d", secs))
687687+ }
688688+ }
689689+ }
690690+ w.ResponseWriter.WriteHeader(code)
691691+}
692692+693693+func (w *retryAfterResponseWriter) Write(b []byte) (int, error) {
694694+ if !w.wroteHeader {
695695+ // Implicit 200 — still fire WriteHeader so flag flips.
696696+ w.WriteHeader(http.StatusOK)
697697+ }
698698+ return w.ResponseWriter.Write(b)
699699+}
700700+701701+// RetryAfterMiddleware installs a per-request RetryAfterCarrier in the
702702+// request context and wraps the response writer so deeper handlers (e.g.,
703703+// the manifest store, when an upstream PDS returns 429) can cause a
704704+// Retry-After header to be emitted on 429 responses.
705705+func RetryAfterMiddleware(next http.Handler) http.Handler {
706706+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
707707+ carrier := storage.NewRetryAfterCarrier()
708708+ ctx := context.WithValue(r.Context(), storage.RetryAfterContextKey, carrier)
709709+ wrapped := &retryAfterResponseWriter{ResponseWriter: w, carrier: carrier}
710710+ next.ServeHTTP(wrapped, r.WithContext(ctx))
711711+ })
712712+}
+3-2
pkg/appview/server.go
···484484 ctx := context.Background()
485485 app := handlers.NewApp(ctx, cfg.Distribution)
486486487487- // Wrap with auth method extraction middleware
488488- wrappedApp := middleware.ExtractAuthMethod(app)
487487+ // Wrap with auth method extraction middleware, then with the Retry-After
488488+ // emitter so it can read the carrier installed before deeper handlers run.
489489+ wrappedApp := middleware.RetryAfterMiddleware(middleware.ExtractAuthMethod(app))
489490490491 // Mount registry at /v2/
491492 mainRouter.Handle("/v2/*", wrappedApp)
+33-1
pkg/appview/storage/manifest_store.go
···1616 "atcr.io/pkg/appview/readme"
1717 "atcr.io/pkg/atproto"
1818 "github.com/distribution/distribution/v3"
1919+ "github.com/distribution/distribution/v3/registry/api/errcode"
1920 "github.com/opencontainers/go-digest"
2021)
2222+2323+// rateLimitToErrcode converts an upstream PDS rate-limit error into a
2424+// distribution errcode.Error (HTTP 429). When ctx contains a
2525+// RetryAfterCarrier, also stashes the retry-after duration so HTTP
2626+// middleware can emit a Retry-After response header. Returns the original
2727+// error untouched when it isn't a rate-limit error.
2828+func rateLimitToErrcode(ctx context.Context, err error) error {
2929+ var rl *atproto.RateLimitError
3030+ if !errors.As(err, &rl) {
3131+ return err
3232+ }
3333+ if rl.RetryAfter > 0 {
3434+ SetRetryAfter(ctx, rl.RetryAfter)
3535+ }
3636+ return errcode.ErrorCodeTooManyRequests.WithMessage(rl.Error())
3737+}
21382239// pullDedup deduplicates pull notifications per puller+owner+repo within a 5-minute window.
2340// This prevents CI workflows (e.g., imagetools create --append) from inflating download counts
···182199 // Upload manifest as blob to PDS
183200 blobRef, err := s.ctx.ATProtoClient.UploadBlob(ctx, payload, mediaType)
184201 if err != nil {
202202+ if rl := rateLimitToErrcode(ctx, err); rl != err {
203203+ return "", rl
204204+ }
185205 return "", fmt.Errorf("failed to upload manifest blob: %w", err)
186206 }
187207···266286 rkey := digestToRKey(dgst)
267287 _, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.ManifestCollection, rkey, manifestRecord)
268288 if err != nil {
289289+ if rl := rateLimitToErrcode(ctx, err); rl != err {
290290+ return "", rl
291291+ }
269292 return "", fmt.Errorf("failed to store manifest record in ATProto: %w", err)
270293 }
271294···292315 tagRecord := atproto.NewTagRecord(s.ctx.ATProtoClient.DID(), s.ctx.Repository, tag, dgst.String(), mediaType)
293316 _, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.TagCollection, tagRKey, tagRecord)
294317 if err != nil {
318318+ if rl := rateLimitToErrcode(ctx, err); rl != err {
319319+ return "", rl
320320+ }
295321 return "", fmt.Errorf("failed to store tag in ATProto: %w", err)
296322 }
297323 }
···389415// Delete removes a manifest
390416func (s *ManifestStore) Delete(ctx context.Context, dgst digest.Digest) error {
391417 rkey := digestToRKey(dgst)
392392- return s.ctx.ATProtoClient.DeleteRecord(ctx, atproto.ManifestCollection, rkey)
418418+ if err := s.ctx.ATProtoClient.DeleteRecord(ctx, atproto.ManifestCollection, rkey); err != nil {
419419+ if rl := rateLimitToErrcode(ctx, err); rl != err {
420420+ return rl
421421+ }
422422+ return err
423423+ }
424424+ return nil
393425}
394426395427// digestToRKey converts a digest to an ATProto record key
+59
pkg/appview/storage/manifest_store_test.go
···44 "context"
55 "encoding/json"
66 "errors"
77+ "fmt"
78 "io"
89 "net/http"
910 "net/http/httptest"
1011 "testing"
1212+ "time"
11131214 "atcr.io/pkg/atproto"
1315 "github.com/distribution/distribution/v3"
1616+ "github.com/distribution/distribution/v3/registry/api/errcode"
1417 "github.com/opencontainers/go-digest"
1518)
1619···961964 t.Errorf("Put() should succeed when all child manifests exist, got error: %v", err)
962965 }
963966}
967967+968968+// TestManifestStore_Put_RateLimitBecomesErrcode verifies that a 429 from the
969969+// upstream PDS surfaces as errcode.ErrorCodeTooManyRequests with a
970970+// Retry-After hint stashed on the carrier in context.
971971+func TestManifestStore_Put_RateLimitBecomesErrcode(t *testing.T) {
972972+ ociManifest := []byte(`{
973973+ "schemaVersion":2,
974974+ "mediaType":"application/vnd.oci.image.manifest.v1+json",
975975+ "config":{"digest":"sha256:cfg","size":1},
976976+ "layers":[{"digest":"sha256:l1","size":1}]
977977+ }`)
978978+979979+ resetAt := time.Now().Add(30 * time.Second).Unix()
980980+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
981981+ // Let the manifest blob upload succeed so we hit putRecord.
982982+ if r.URL.Path == atproto.RepoUploadBlob {
983983+ w.WriteHeader(http.StatusOK)
984984+ w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafytest"},"mimeType":"application/json","size":1}}`))
985985+ return
986986+ }
987987+ // putRecord returns 429.
988988+ w.Header().Set("ratelimit-limit", "100")
989989+ w.Header().Set("ratelimit-remaining", "0")
990990+ w.Header().Set("ratelimit-reset", fmt.Sprintf("%d", resetAt))
991991+ w.WriteHeader(http.StatusTooManyRequests)
992992+ w.Write([]byte(`{"error":"RateLimitExceeded","message":"Rate Limit Exceeded"}`))
993993+ }))
994994+ defer server.Close()
995995+996996+ client := atproto.NewClient(server.URL, "did:plc:test123", "token")
997997+ db := &mockHoldDIDLookup{}
998998+ rctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:test123", "test.handle", db)
999999+ store := NewManifestStore(rctx, nil)
10001000+10011001+ carrier := NewRetryAfterCarrier()
10021002+ ctx := context.WithValue(context.Background(), RetryAfterContextKey, carrier)
10031003+10041004+ _, err := store.Put(ctx, &rawManifest{
10051005+ mediaType: "application/vnd.oci.image.manifest.v1+json",
10061006+ payload: ociManifest,
10071007+ })
10081008+ if err == nil {
10091009+ t.Fatal("expected error, got nil")
10101010+ }
10111011+10121012+ var ec errcode.Error
10131013+ if !errors.As(err, &ec) {
10141014+ t.Fatalf("expected errcode.Error, got %T: %v", err, err)
10151015+ }
10161016+ if ec.Code != errcode.ErrorCodeTooManyRequests {
10171017+ t.Errorf("Code = %v, want ErrorCodeTooManyRequests", ec.Code)
10181018+ }
10191019+ if got := carrier.Duration(); got <= 0 {
10201020+ t.Errorf("expected carrier to have a Retry-After duration, got %v", got)
10211021+ }
10221022+}
+57
pkg/appview/storage/retryafter.go
···11+package storage
22+33+import (
44+ "context"
55+ "sync"
66+ "time"
77+)
88+99+// RetryAfterCarrier is a request-scoped, mutable container for a Retry-After
1010+// hint emitted by storage handlers (e.g., when an upstream PDS returns 429).
1111+// HTTP middleware injects an empty carrier into the request context; deep
1212+// handlers populate it via SetRetryAfter when they convert a rate-limit error
1313+// into a 429 response. The middleware then reads it back to set the
1414+// Retry-After response header.
1515+type RetryAfterCarrier struct {
1616+ mu sync.Mutex
1717+ duration time.Duration
1818+}
1919+2020+const RetryAfterContextKey contextKey = "atcr.retry-after"
2121+2222+// NewRetryAfterCarrier returns an empty carrier ready to be stored in context.
2323+func NewRetryAfterCarrier() *RetryAfterCarrier {
2424+ return &RetryAfterCarrier{}
2525+}
2626+2727+// Set records a retry-after hint. Largest value wins (a later, longer
2828+// throttle window in a multi-write request shouldn't be clobbered by a
2929+// shorter one).
3030+func (c *RetryAfterCarrier) Set(d time.Duration) {
3131+ if c == nil || d <= 0 {
3232+ return
3333+ }
3434+ c.mu.Lock()
3535+ if d > c.duration {
3636+ c.duration = d
3737+ }
3838+ c.mu.Unlock()
3939+}
4040+4141+// Duration returns the recorded retry-after value, or 0 if none was set.
4242+func (c *RetryAfterCarrier) Duration() time.Duration {
4343+ if c == nil {
4444+ return 0
4545+ }
4646+ c.mu.Lock()
4747+ defer c.mu.Unlock()
4848+ return c.duration
4949+}
5050+5151+// SetRetryAfter is a convenience helper for handlers that have a context but
5252+// not a direct carrier reference.
5353+func SetRetryAfter(ctx context.Context, d time.Duration) {
5454+ if c, ok := ctx.Value(RetryAfterContextKey).(*RetryAfterCarrier); ok {
5555+ c.Set(d)
5656+ }
5757+}
+49
pkg/atproto/client.go
···2323 ErrRecordNotFound = errors.New("record not found")
2424)
25252626+// RateLimitError indicates that the upstream PDS returned 429 (RateLimitExceeded).
2727+// It carries an optional RetryAfter duration derived from PDS rate-limit headers
2828+// so callers can surface it to clients (e.g., as a Retry-After response header).
2929+type RateLimitError struct {
3030+ Wrapped error
3131+ RetryAfter time.Duration // 0 if unknown
3232+}
3333+3434+func (e *RateLimitError) Error() string {
3535+ if e.Wrapped != nil {
3636+ return e.Wrapped.Error()
3737+ }
3838+ return "rate limit exceeded"
3939+}
4040+4141+func (e *RateLimitError) Unwrap() error { return e.Wrapped }
4242+4343+// asRateLimitError inspects err and, if it represents a 429 from the PDS,
4444+// returns a *RateLimitError wrapping it. Returns nil otherwise.
4545+func asRateLimitError(err error) *RateLimitError {
4646+ if err == nil {
4747+ return nil
4848+ }
4949+ var xrpcErr *xrpc.Error
5050+ if errors.As(err, &xrpcErr) && xrpcErr.StatusCode == http.StatusTooManyRequests {
5151+ var retryAfter time.Duration
5252+ if xrpcErr.Ratelimit != nil && !xrpcErr.Ratelimit.Reset.IsZero() {
5353+ if d := time.Until(xrpcErr.Ratelimit.Reset); d > 0 {
5454+ retryAfter = d
5555+ }
5656+ }
5757+ return &RateLimitError{Wrapped: err, RetryAfter: retryAfter}
5858+ }
5959+ var apiErr *atclient.APIError
6060+ if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests {
6161+ return &RateLimitError{Wrapped: err}
6262+ }
6363+ return nil
6464+}
6565+2666// ClientProvider abstracts OAuth vs Basic Auth client creation.
2767// This allows the same code path for all PDS operations regardless of auth type.
2868type ClientProvider interface {
···146186 return client.LexDo(ctx, "POST", "application/json", "com.atproto.repo.putRecord", nil, payload, &result)
147187 })
148188 if err != nil {
189189+ if rl := asRateLimitError(err); rl != nil {
190190+ return nil, rl
191191+ }
149192 return nil, fmt.Errorf("putRecord failed: %w", err)
150193 }
151194 return &result, nil
···198241 return client.LexDo(ctx, "POST", "application/json", "com.atproto.repo.deleteRecord", nil, payload, &result)
199242 })
200243 if err != nil {
244244+ if rl := asRateLimitError(err); rl != nil {
245245+ return rl
246246+ }
201247 return fmt.Errorf("deleteRecord failed: %w", err)
202248 }
203249 return nil
···250296 return client.LexDo(ctx, "POST", mimeType, "com.atproto.repo.uploadBlob", nil, bytes.NewReader(data), &result)
251297 })
252298 if err != nil {
299299+ if rl := asRateLimitError(err); rl != nil {
300300+ return nil, rl
301301+ }
253302 return nil, fmt.Errorf("uploadBlob failed: %w", err)
254303 }
255304 return &result.Blob, nil
+53
pkg/atproto/client_test.go
···33import (
44 "context"
55 "encoding/json"
66+ "errors"
77+ "fmt"
68 "net/http"
79 "net/http/httptest"
810 "strings"
···10431045 t.Error("Expected error from GetBlob, got nil")
10441046 }
10451047}
10481048+10491049+// TestPutRecord_RateLimited verifies that a 429 from the PDS surfaces as a
10501050+// *RateLimitError carrying the Retry-After hint derived from ratelimit-reset.
10511051+func TestPutRecord_RateLimited(t *testing.T) {
10521052+ resetAt := time.Now().Add(45 * time.Second).Unix()
10531053+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
10541054+ w.Header().Set("ratelimit-limit", "100")
10551055+ w.Header().Set("ratelimit-remaining", "0")
10561056+ w.Header().Set("ratelimit-reset", fmt.Sprintf("%d", resetAt))
10571057+ w.WriteHeader(http.StatusTooManyRequests)
10581058+ w.Write([]byte(`{"error":"RateLimitExceeded","message":"Rate Limit Exceeded"}`))
10591059+ }))
10601060+ defer server.Close()
10611061+10621062+ client := NewClient(server.URL, "did:plc:test123", "test-token")
10631063+ _, err := client.PutRecord(context.Background(), ManifestCollection, "abc", map[string]any{"k": "v"})
10641064+ if err == nil {
10651065+ t.Fatal("expected error, got nil")
10661066+ }
10671067+10681068+ var rl *RateLimitError
10691069+ if !errors.As(err, &rl) {
10701070+ t.Fatalf("expected *RateLimitError, got %T: %v", err, err)
10711071+ }
10721072+ if rl.RetryAfter <= 0 {
10731073+ t.Errorf("expected non-zero RetryAfter, got %v", rl.RetryAfter)
10741074+ }
10751075+ if rl.RetryAfter > 60*time.Second {
10761076+ t.Errorf("RetryAfter %v exceeds expected upper bound", rl.RetryAfter)
10771077+ }
10781078+}
10791079+10801080+// TestPutRecord_NonRateLimitErrorPassthrough verifies that non-429 errors
10811081+// are not coerced into RateLimitError.
10821082+func TestPutRecord_NonRateLimitErrorPassthrough(t *testing.T) {
10831083+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
10841084+ w.WriteHeader(http.StatusBadRequest)
10851085+ w.Write([]byte(`{"error":"InvalidRequest","message":"bad"}`))
10861086+ }))
10871087+ defer server.Close()
10881088+10891089+ client := NewClient(server.URL, "did:plc:test123", "test-token")
10901090+ _, err := client.PutRecord(context.Background(), ManifestCollection, "abc", map[string]any{"k": "v"})
10911091+ if err == nil {
10921092+ t.Fatal("expected error, got nil")
10931093+ }
10941094+ var rl *RateLimitError
10951095+ if errors.As(err, &rl) {
10961096+ t.Fatalf("did not expect *RateLimitError for 400, got %v", err)
10971097+ }
10981098+}