164 lines
7.5 KiB
Go
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")
|