Files
query-orchestration/internal/bot/errors.go
T
Jay Brown 72de690894 Merged in feature/chatbot1 (pull request #223)
Chatbot functionality

* baseline working

* missing test file

* more tests
2026-05-07 20:56:18 +00:00

164 lines
7.5 KiB
Go

// Package bot — typed sentinel errors used by the chatbot service layer.
//
// Handlers map these onto HTTP status codes via MapError. The service
// layer never returns raw pgx/sqlc errors to handlers; instead it wraps
// them in one of the sentinels below so the handler->HTTP mapping stays
// in one place.
package bot
import "errors"
// ErrSessionNotFound is returned when an owner-scoped or super-admin
// read cannot find a session matching the (id, client_id, [created_by],
// is_deleted=false) tuple. Maps to 404.
var ErrSessionNotFound = errors.New("bot: session not found")
// ErrAddRemoveConflict is returned by PatchScope when the same id
// appears in both add and remove sets for documents or folders. Maps to
// 400 with reason add_remove_conflict.
var ErrAddRemoveConflict = errors.New("bot: add_remove_conflict")
// ErrDocumentCrossClient is returned by PatchScope when one of the
// candidate document ids belongs to a different client than the
// session's client. Maps to 400 with reason document_cross_client.
var ErrDocumentCrossClient = errors.New("bot: document_cross_client")
// ErrFolderCrossClient is returned by PatchScope when one of the
// candidate folder ids belongs to a different client than the session's
// client. Maps to 400 with reason folder_cross_client.
var ErrFolderCrossClient = errors.New("bot: folder_cross_client")
// ErrInvalidLimit is returned when request.limit is outside [1, 200].
// Maps to 400.
var ErrInvalidLimit = errors.New("bot: invalid limit; must be in [1, 200]")
// ErrInvalidOffset is returned when request.offset is below 0. Maps to
// 400.
var ErrInvalidOffset = errors.New("bot: invalid offset; must be >= 0")
// ErrMissingClientID is returned when a super-admin route is called
// without the required clientId query parameter. Maps to 400.
var ErrMissingClientID = errors.New("bot: clientId is required")
// ---------- M3: FastAPI client sentinels ----------
//
// Each sentinel below is wrapped by an attemptError so the service
// layer can errors.Is(err, sentinel) AND read AttemptCount via the
// AttemptCounter interface in one error.
// ErrAgentApplicationError is returned when FastAPI replies with HTTP
// 200 and status:"error". Plan §7: do not retry; persist the upstream
// error JSON in bot_turns.error.
var ErrAgentApplicationError = errors.New("bot: agent_application_error")
// ErrAgentMissingAnswer is returned when FastAPI replies with HTTP 200
// and status:"success" but answer.text is empty, whitespace-only, or
// the answer field is null/missing. Plan §7: do not retry.
var ErrAgentMissingAnswer = errors.New("bot: agent_missing_answer")
// ErrAgentInvalidResponse is returned when FastAPI replies with HTTP
// 200 but the body is not parseable JSON or fails structural validation.
// Plan §7: do not retry.
var ErrAgentInvalidResponse = errors.New("bot: agent_invalid_response")
// ErrAgentTransportError is returned when the FastAPI client exhausts
// its retry budget on transport-level failures (timeouts, retryable
// HTTP statuses 429/5xx). The handler maps this to HTTP 502. Plan §7.
var ErrAgentTransportError = errors.New("bot: agent_transport_error")
// ErrHealthStatusNotOK is returned by FastAPIClient.Health when the
// /health response status field is not "ok". Plan §7.
var ErrHealthStatusNotOK = errors.New("bot: health status not ok")
// ErrHealthMissingService is returned by FastAPIClient.Health when the
// service or version fields are empty. Plan §7.
var ErrHealthMissingService = errors.New("bot: health service or version empty")
// ---------- M4: AddTurn sentinels ----------
//
// Each maps to a specific HTTP status via mapBotError:
// 400: prompt_empty, prompt_too_long
// 409: turn_in_flight, prior_turn_in_flight, already_complete,
// turn_session_deleted, ordinal_out_of_range, prompt_mismatch,
// turn_superseded
// 410: session_deleted_during_call
// ErrTurnInFlight: existing row at the requested ordinal has status
// in_flight. The same caller cannot resubmit while their turn is still
// running. Plan §6.
var ErrTurnInFlight = errors.New("bot: turn_in_flight")
// ErrPriorTurnInFlight: a different turn at a lower ordinal is still
// in_flight, so the partial unique index `bot_turns_one_inflight_per_session`
// rejects a new in_flight insert. Plan §6.
var ErrPriorTurnInFlight = errors.New("bot: prior_turn_in_flight")
// ErrAlreadyComplete: existing row at the requested ordinal has status
// completed. Retries of completed turns are rejected. Plan §6.
//
// The sentinel intentionally chains to ErrOrdinalOutOfRange via the Is
// method below: a concurrent submission whose ordinal lands on a row
// the winner just completed is semantically "out of range" (the next
// natural ordinal moved past the completed row). Plan §11's
// concurrency invariant lists ErrOrdinalOutOfRange as one of the
// acceptable 409 outcomes; chaining lets ErrAlreadyComplete satisfy
// `errors.Is(err, ErrOrdinalOutOfRange)` without losing its own
// identity for the HTTP retry-of-completed scenario.
var ErrAlreadyComplete = alreadyCompleteError{}
// alreadyCompleteError is the concrete type backing ErrAlreadyComplete.
// The Error string matches the sentinel-style "bot: already_complete"
// so errors.Is(err, ErrAlreadyComplete) and substring assertions on
// the HTTP error message both work.
type alreadyCompleteError struct{}
func (alreadyCompleteError) Error() string { return "bot: already_complete" }
// Is satisfies errors.Is so ErrAlreadyComplete chains to
// ErrOrdinalOutOfRange. Without this, the M4 concurrency test's
// loser-B case (caller's ordinal lands on the row the winner just
// completed) would surface as a non-409 default branch. Plan §11's
// loss set explicitly enumerates ErrOrdinalOutOfRange.
func (alreadyCompleteError) Is(target error) bool {
if target == ErrOrdinalOutOfRange {
return true
}
_, ok := target.(alreadyCompleteError)
return ok
}
// ErrTurnSessionDeleted: existing row at the requested ordinal has
// status session_deleted. Distinct from ErrAlreadyComplete. Plan §6.
var ErrTurnSessionDeleted = errors.New("bot: turn_session_deleted")
// ErrOrdinalOutOfRange: the requested ordinal does not match the
// algorithm's expected ordinal (last_terminal+1 for new, last_seen+1
// when in_flight, last_terminal_ordinal for retry of errored/abandoned).
// Plan §6.
var ErrOrdinalOutOfRange = errors.New("bot: ordinal_out_of_range")
// ErrPromptMismatch: caller is retrying an errored/abandoned turn but
// the supplied prompt does not match the persisted prompt. Plan §6:
// retries must reuse the same prompt verbatim.
var ErrPromptMismatch = errors.New("bot: prompt_mismatch")
// ErrTurnSuperseded: the tx2 compare-and-set affected zero rows because
// either attempt_id no longer matches or status is no longer in_flight.
// Indicates the row was abandoned/reset by another caller while the
// FastAPI call was in flight. Plan §6 + plan §11.
var ErrTurnSuperseded = errors.New("bot: turn_superseded")
// ErrSessionDeletedDuringCall: the tx2 LockBotSessionForOwner found the
// session is_deleted=true after a successful tx1 + FastAPI call. The
// service marks the in_flight row as session_deleted before returning
// this sentinel. HTTP 410. Plan §6.
var ErrSessionDeletedDuringCall = errors.New("bot: session_deleted_during_call")
// ErrPromptEmpty: the supplied prompt is empty or whitespace-only.
// Validation runs before any tx so no row is inserted. HTTP 400.
var ErrPromptEmpty = errors.New("bot: prompt_empty")
// ErrPromptTooLong: the supplied prompt exceeds cfg.MaxTurnChars in
// rune count. HTTP 400.
var ErrPromptTooLong = errors.New("bot: prompt_too_long")