Integration fix 2: conditional recipe repairs and no pasted links in logs (Feature 12 review F1, F2) (#96)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #96.
This commit is contained in:
@@ -200,7 +200,11 @@ func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (a
|
||||
// writeDinnerError maps a dinners.Service error to its API error. A page
|
||||
// that cannot ground the dinner is 422 ungroundable; recipe search not set
|
||||
// up is 409 not_configured; a search or model failure is 502; grounding by
|
||||
// hand that runs past dinners.GroundTimeout is 504 ground_timeout.
|
||||
// hand that runs past dinners.GroundTimeout is 504 ground_timeout; grounding
|
||||
// an archived dinner is 409 dinner_archived and one changed while grounding
|
||||
// 409 ground_conflict. Any other grounding failure, by candidate, link or
|
||||
// manual recipe, wraps dinners.ErrGroundFailed and is 500, logged by reason
|
||||
// and dinner id only.
|
||||
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var invalid *dinners.ValidationError
|
||||
var dup *dinners.DuplicateError
|
||||
@@ -220,6 +224,16 @@ func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logge
|
||||
case errors.Is(err, recipes.ErrNormalizeFailed):
|
||||
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed")
|
||||
writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
|
||||
case errors.Is(err, dinners.ErrGroundFailed):
|
||||
// The error may quote the pasted link, which can carry a personal
|
||||
// token, and dependency errors, so only the reason and dinner are
|
||||
// logged.
|
||||
logger.ErrorContext(r.Context(), action, "reason", "ground_failed", "dinner_id", chi.URLParam(r, "id"))
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
case errors.Is(err, dinners.ErrArchived):
|
||||
writeError(w, r, logger, http.StatusConflict, "dinner_archived", "the dinner is archived")
|
||||
case errors.Is(err, dinners.ErrGroundConflict):
|
||||
writeError(w, r, logger, http.StatusConflict, "ground_conflict", "the dinner changed while its recipe was grounding; reload and try again")
|
||||
case errors.Is(err, dinners.ErrGroundTimeout):
|
||||
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
|
||||
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
|
||||
|
||||
@@ -224,6 +224,115 @@ func TestPostGroundViaRecipeURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundViaRecipeURLStorageFailure: a pasted link that fails for a
|
||||
// reason other than its recipe is a 500 logged by reason and dinner id,
|
||||
// never with the link, which can carry a personal token, or the dependency
|
||||
// error.
|
||||
func TestPostGroundViaRecipeURLStorageFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
const token = "personal-token-abc123"
|
||||
link := stewPage + "?access_token=" + token
|
||||
e.pages.Bodies[link] = e.pages.Bodies[stewPage]
|
||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
||||
`CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+link+`"}`)
|
||||
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" || strings.Contains(rec.Body.String(), token) {
|
||||
t.Errorf("POST = %d %s, want 500 internal without the link", rec.Code, rec.Body)
|
||||
}
|
||||
logs := e.logs.String()
|
||||
if strings.Contains(logs, token) || strings.Contains(logs, "c.example") || strings.Contains(logs, "disk full") ||
|
||||
!strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
||||
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the link or dependency error", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundViaRecipeURLDinnerWriteFailure: a pasted link whose recipe
|
||||
// is stored but whose dinner cannot be written is a 500 logged by reason
|
||||
// and dinner id, never with the dependency error.
|
||||
func TestPostGroundViaRecipeURLDinnerWriteFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
||||
`CREATE TRIGGER refuse_grounding BEFORE UPDATE OF recipe_id ON dinners WHEN NEW.recipe_id IS NOT NULL BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
|
||||
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
|
||||
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
|
||||
}
|
||||
var stored int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM recipes WHERE source_url = ?`, stewPage).Scan(&stored); err != nil || stored != 1 {
|
||||
t.Fatalf("recipes stored for the link = %d (%v), want 1: the failure must be the dinner write", stored, err)
|
||||
}
|
||||
logs := e.logs.String()
|
||||
if strings.Contains(logs, "disk full") || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
||||
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the dependency error", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundUnexpectedFailures: grounding by hand that fails reading
|
||||
// the dinner, or storing a manual recipe, is a 500 logged by reason and
|
||||
// dinner id, never with the database error.
|
||||
func TestPostGroundUnexpectedFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
// Renaming a column every dinner read selects fails the read.
|
||||
breakRead = `ALTER TABLE dinners RENAME COLUMN cuisine TO cuisine_gone`
|
||||
// The trigger's message stands in for the database error.
|
||||
breakStore = `CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`
|
||||
manual = `{"manual":{"ingredients":["1 lb beef"],"steps":["Brown."],"minutes":240,"method":"crockpot"}}`
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name, sabotage, fix, leak string
|
||||
}{
|
||||
{"link read", breakRead, `{"recipe_url":"` + stewPage + `"}`, "cuisine"},
|
||||
{"manual read", breakRead, manual, "cuisine"},
|
||||
{"manual store", breakStore, manual, "disk full"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", tc.fix)
|
||||
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
|
||||
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
|
||||
}
|
||||
logs := e.logs.String()
|
||||
if strings.Contains(logs, tc.leak) || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
||||
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the database error", logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundArchived: grounding an archived dinner by hand is a 409.
|
||||
func TestPostGroundArchived(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
id := createDinner(t, e.h, `{"name":"Family chili"}`)
|
||||
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
||||
`{"manual":{"ingredients":["1 lb beef"],"minutes":240,"method":"crockpot"}}`)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_archived" {
|
||||
t.Errorf("POST = %d %v, want 409 dinner_archived", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostGroundViaManual(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
|
||||
Reference in New Issue
Block a user