| index 4a7ee8cd0..ae63a84f5 100644 |
| --- a/internal/console/apischedule.go |
| +++ b/internal/console/apischedule.go |
| @@ -65,6 +65,14 @@ func scheduleAnswer(acct, app string, d schedule.Declaration, insts []schedule.S |
| return out |
| } |
| |
| +// scheduleWriteFailed is the 502 sentence handleAPIScheduleSet and |
| +// handleAPIScheduleRemove both give a bucket write failure — identical |
| +// shape, one verb apart — so a member reads the same "nothing changed" out |
| +// of either, and the wording lives in one place to say so. |
| +func scheduleWriteFailed(verb string) string { |
| + return "could not " + verb + " the schedule — nothing changed; try again" |
| +} |
| + |
| // apiScheduleApp is the shared gate of all four doors, in one place so the |
| // ORDER cannot drift between them: |
| // |
| @@ -213,17 +221,29 @@ func (s *Server) handleAPIScheduleSet(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| defer cancel() |
| - if cur, _, err := schedule.ReadDeclaration(ctx, s.cfg.Releases, acct.Sqid, app); err == nil { |
| - if _, replacing := cur.Find(sc.Name); !replacing && len(cur.Schedules) >= schedule.MaxSchedulesPerApp { |
| - jsonError(w, http.StatusBadRequest, fmt.Sprintf( |
| - "an app may declare at most %d schedules — remove one first", schedule.MaxSchedulesPerApp)) |
| - return |
| - } |
| + // ReadDeclaration already folds a genuine not-found into an empty, |
| + // nil-error Declaration (spec §4.1: absence is legal forever) — so a |
| + // non-nil err here is a real read failure (bucket unreachable, bad |
| + // JSON), not "this app has no schedules yet". Silently skipping the |
| + // cap check in that case would let a request past the door's own |
| + // answer and rely entirely on UpsertSchedule's CAS-guarded re-check, |
| + // which is there for the GUARANTEE, not to cover for a 502 this door |
| + // should have surfaced itself. |
| + cur, _, err := schedule.ReadDeclaration(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: api schedule set %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, scheduleWriteFailed("record")) |
| + return |
| + } |
| + if _, replacing := cur.Find(sc.Name); !replacing && len(cur.Schedules) >= schedule.MaxSchedulesPerApp { |
| + jsonError(w, http.StatusBadRequest, fmt.Sprintf( |
| + "an app may declare at most %d schedules — remove one first", schedule.MaxSchedulesPerApp)) |
| + return |
| } |
| d, err := schedule.UpsertSchedule(ctx, s.cfg.Releases, acct.Sqid, app, sc, time.Now()) |
| if err != nil { |
| log.Printf("console: api schedule set %s/%s %s: %v", acct.Sqid, app, sc.Name, err) |
| - jsonError(w, http.StatusBadGateway, "could not record the schedule — nothing changed; try again") |
| + jsonError(w, http.StatusBadGateway, scheduleWriteFailed("record")) |
| return |
| } |
| s.audit(acct, app, addr, "schedule set", "recorded schedule "+sc.Name+" (cli)") |
| @@ -251,10 +271,16 @@ func (s *Server) handleAPIScheduleRemove(w http.ResponseWriter, r *http.Request) |
| if !ok { |
| return |
| } |
| + // present (RemoveSchedule's second return) is ignored: declaredSchedule |
| + // just confirmed sc.Name was declared, and RemoveSchedule is idempotent |
| + // by design (its own doc comment — removing an absent name still |
| + // rewrites updated_at). A concurrent remove landing between that read |
| + // and this call is the same successful outcome either way, not |
| + // something for this door to distinguish. |
| d, _, err := schedule.RemoveSchedule(ctx, s.cfg.Releases, acct.Sqid, app, sc.Name, time.Now()) |
| if err != nil { |
| log.Printf("console: api schedule rm %s/%s %s: %v", acct.Sqid, app, sc.Name, err) |
| - jsonError(w, http.StatusBadGateway, "could not remove the schedule — nothing changed; try again") |
| + jsonError(w, http.StatusBadGateway, scheduleWriteFailed("remove")) |
| return |
| } |
| s.audit(acct, app, addr, "schedule rm", "removed schedule "+sc.Name+" (cli)") |