carlos / platform Public

zdd-6 final fix wave 2: X1 a 401 from the instance list is not fallback evidence

A 401 is retried to the deadline and the timeout names the list failure, like a 5xx. A 403 stays fallback evidence: a Publish-only token can deploy but not read the list, and gets today's URL watch. Astra plan 6 R2 #4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5150a6c199944824f414323b78abaa08dd6a62a6 parent 3625973
2 files changed, +42 −22
  • deploywatch.go +13 −6
  • deploywatch_test.go +29 −16
diff --git a/deploywatch.go b/deploywatch.go
index b97c1c412..56f00ccf4 100644
--- a/deploywatch.go
+++ b/deploywatch.go
@@ -99,14 +99,21 @@ type listStatusError struct {
func (e *listStatusError) Error() string { return e.err.Error() }
func (e *listStatusError) Unwrap() error { return e.err }
-// listAbsent reports whether a failed list is affirmative evidence that no
-// box reports on this app (Astra plan 6 R1 #4): a 4xx other than 429 says
-// the console has no list for it (a console from before the door, an app
-// with no instances). A 5xx, a 429, a network error or an unreadable answer
-// says nothing about the boxes.
+// listAbsent reports whether a failed list is evidence enough to fall back
+// to today's URL watch (Astra plan 6 R1 #4, R2 #4). A 4xx says the console
+// has no list for this caller: a console from before the door, an app with
+// no instances (404). A 403 counts too, on purpose: a Publish-only token can
+// deploy (promote admits Publish) but cannot read the list (GET instances
+// admits Instance and Deploy), so such a deploy gets exactly the watch it
+// got before reports existed. Not a 401: the deploy has just authenticated
+// with this token, so a 401 is a session that lapsed or a console that
+// stumbled, and says nothing about the boxes. Not a 429 either. Those, a
+// 5xx, a network error and an unreadable answer are retried until the
+// deadline.
func listAbsent(err error) bool {
var se *listStatusError
- return errors.As(err, &se) && se.status >= 400 && se.status < 500 && se.status != http.StatusTooManyRequests
+ return errors.As(err, &se) && se.status >= 400 && se.status < 500 &&
+ se.status != http.StatusTooManyRequests && se.status != http.StatusUnauthorized
}
// watchInstanceList is the watch's read of the instance list. It is
diff --git a/deploywatch_test.go b/deploywatch_test.go
index e9e09ad44..38a4ca0bb 100644
--- a/deploywatch_test.go
+++ b/deploywatch_test.go
@@ -653,8 +653,11 @@ func TestWatchRolloutFailedFirstListsNeverFallBack(t *testing.T) {
// the timeout names the list failure.
func TestWatchRolloutListFailuresTimeOutNamingTheList(t *testing.T) {
for name, lerr := range map[string]error{
- "503": &listStatusError{status: http.StatusServiceUnavailable, err: errors.New("could not list instances")},
- "429": &listStatusError{status: http.StatusTooManyRequests, err: errors.New("too many requests")},
+ "503": &listStatusError{status: http.StatusServiceUnavailable, err: errors.New("could not list instances")},
+ "429": &listStatusError{status: http.StatusTooManyRequests, err: errors.New("too many requests")},
+ // Astra plan 6 R2 #4: the deploy just authenticated with this
+ // token, so a 401 says nothing about the boxes.
+ "401": &listStatusError{status: http.StatusUnauthorized, err: errors.New("not signed in")},
"network": errors.New("dial tcp: connection refused"),
} {
origin, hits := hitServer(t)
@@ -676,20 +679,26 @@ func TestWatchRolloutListFailuresTimeOutNamingTheList(t *testing.T) {
}
}
-// Astra plan 6 R1 #4: a 4xx other than 429 is a console with no list for
-// the app, so the watch is today's, as before.
-func TestWatchRolloutList404FallsBack(t *testing.T) {
- origin, hits := hitServer(t)
- var calls atomic.Int64
- var out bytes.Buffer
- rw := testWatch(&out, scripted(&calls, sweep{err: &listStatusError{status: http.StatusNotFound, err: notFoundErr()}}), nil)
- onURL := 0
- rw.onURL = func() { onURL++ }
- if err := rw.run([]watchTarget{{Host: "x.test", Origin: origin}}); err != nil {
- t.Fatal(err)
- }
- if calls.Load() != 1 || onURL != 1 || hits.Load() == 0 || out.String() != "live https://x.test\n" {
- t.Fatalf("%d lists, onURL %d, %d HTTP polls, output %q", calls.Load(), onURL, hits.Load(), out.String())
+// Astra plan 6 R1 #4, R2 #4: a 404 is a console with no list for the app,
+// and a 403 a principal that may deploy but not read the list (a
+// Publish-only token): either way the watch is today's, as before.
+func TestWatchRolloutList404And403FallBack(t *testing.T) {
+ for _, lerr := range []*listStatusError{
+ {status: http.StatusNotFound, err: notFoundErr()},
+ {status: http.StatusForbidden, err: errors.New("forbidden")},
+ } {
+ origin, hits := hitServer(t)
+ var calls atomic.Int64
+ var out bytes.Buffer
+ rw := testWatch(&out, scripted(&calls, sweep{err: lerr}), nil)
+ onURL := 0
+ rw.onURL = func() { onURL++ }
+ if err := rw.run([]watchTarget{{Host: "x.test", Origin: origin}}); err != nil {
+ t.Fatalf("%d: %v", lerr.status, err)
+ }
+ if calls.Load() != 1 || onURL != 1 || hits.Load() == 0 || out.String() != "live https://x.test\n" {
+ t.Fatalf("%d: %d lists, onURL %d, %d HTTP polls, output %q", lerr.status, calls.Load(), onURL, hits.Load(), out.String())
+ }
}
}
@@ -795,6 +804,10 @@ func TestDeployWatchReadsTheConsolesList(t *testing.T) {
io.WriteString(w, `{"error":"could not list instances"}`)
}, false, watchListFailed + ": could not list instances"},
{"404", func(w http.ResponseWriter) { w.WriteHeader(http.StatusNotFound) }, true, ""},
+ {"401", func(w http.ResponseWriter) {
+ w.WriteHeader(http.StatusUnauthorized)
+ io.WriteString(w, `{"error":"not signed in"}`)
+ }, false, watchListFailed + ": not signed in"},
{"incomplete join", func(w http.ResponseWriter) {
io.WriteString(w, `{"instances":[{"host":"x.test"}],"truncated":false,"rollout_incomplete":true}`)
}, false, "x.test: " + watchPendingUnread},