package bot import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "strings" "time" "queryorchestration/internal/serviceconfig/chatbot" ) // fastAPIMaxAttempts is the hard upper bound on Chat retry attempts per // plan §7 ("Up to 3 attempts"). Exposed as a constant so the retry // matrix tests reference a single source of truth. const fastAPIMaxAttempts = 3 // fastAPIChatPath is the FastAPI agent service's chat endpoint per the // swagger. Concatenated to cfg.ServiceURL at request time. const fastAPIChatPath = "/agent/chat" // fastAPIHealthPath is the standard health probe path. Plan §7 uses it // for the boot-time smoke check. const fastAPIHealthPath = "/health" // apiKeyHeader is the literal header name plan §11 mandates. The value // is whatever cfg.APIKey carries verbatim — including the JSON-encoded // string the live deployment expects. The constant is the header name // only; the credential lives in cfg.APIKey. const apiKeyHeader = "X-API-Key" //nolint:gosec // header name, not a credential // FastAPIClient is the queryAPI-side FastAPI agent service client. Plan // §7 specifies the request/response mapping and retry policy this struct // implements. type FastAPIClient struct { cfg chatbot.ChatbotConfig baseURL string httpClient *http.Client } // FastAPIOption mutates a FastAPIClient at construction time. Used by // tests to override the baseURL (httptest.Server.URL) or substitute a // custom http.Client (e.g., to inject a transport). type FastAPIOption func(*FastAPIClient) // WithBaseURL overrides cfg.ServiceURL. Tests set this to the // httptest.Server.URL so requests dial the local stub. func WithBaseURL(url string) FastAPIOption { return func(c *FastAPIClient) { c.baseURL = url } } // WithHTTPClient substitutes the default http.Client. Tests use this // when they need full control over the round-tripper. func WithHTTPClient(client *http.Client) FastAPIOption { return func(c *FastAPIClient) { if client != nil { c.httpClient = client } } } // NewFastAPIClient constructs a FastAPIClient. The default http.Client // has no per-call timeout because Chat applies a per-attempt deadline // derived from cfg.RequestTimeoutSeconds via context.WithTimeout, and // imposing a Client.Timeout on top would interfere with the parent // context cancellation tests. func NewFastAPIClient(cfg chatbot.ChatbotConfig, opts ...FastAPIOption) *FastAPIClient { c := &FastAPIClient{ cfg: cfg, baseURL: strings.TrimRight(cfg.ServiceURL, "/"), httpClient: &http.Client{}, } for _, opt := range opts { opt(c) } c.baseURL = strings.TrimRight(c.baseURL, "/") return c } // Chat sends one chat request to FastAPI with retry. Plan §7 retry // policy: // - Up to 3 attempts. // - Retry HTTP 429, 500, 502, 503, 504, and transport timeouts. // - Do not retry other 4xx responses. // - Do not retry HTTP 200 with status:"error". // - Reuse the same request_id for every attempt (caller responsibility). // - One parent context enforces total wall time. // // Returns ChatCallResult on success. On failure returns an attemptError // implementing AttemptCounter so the caller can extract the count via // errors.As(err, &counter). func (c *FastAPIClient) Chat(ctx context.Context, req ChatRequest) (ChatCallResult, error) { body, err := json.Marshal(req) if err != nil { return ChatCallResult{}, &attemptError{ sentinel: ErrAgentInvalidResponse, attempts: 0, cause: fmt.Errorf("marshal request: %w", err), } } url := c.baseURL + fastAPIChatPath timeout := time.Duration(c.cfg.RequestTimeoutSeconds) * time.Second sentAt := time.Now() var lastErr error var lastResponseBody []byte var lastStatus int for attempt := 1; attempt <= fastAPIMaxAttempts; attempt++ { // Bail before the next attempt when the parent ctx has expired. // errors.Is preserves context.Canceled / context.DeadlineExceeded // so the test suite can assert on the wrapped sentinel. if ctxErr := ctx.Err(); ctxErr != nil { slog.Warn("bot.fastapi.context_canceled", "request_id", req.RequestID, "attempts_completed", attempt-1, "cause", ctxErr.Error(), ) return ChatCallResult{}, &attemptError{ sentinel: nil, attempts: attempt - 1, cause: ctxErr, } } attemptStart := time.Now() result, err := c.attemptChat(ctx, url, body, timeout, sentAt, attempt) latencyMs := time.Since(attemptStart).Milliseconds() if err == nil { slog.Debug("bot.fastapi.attempt", "request_id", req.RequestID, "attempt", attempt, "status_code", http.StatusOK, "latency_ms", latencyMs, "outcome", "success", ) return result, nil } // The attempt classified its own outcome; pull the classification // off the error so the loop can decide whether to retry. var classified *attemptError if !errors.As(err, &classified) { // Defensive: every attempt path returns *attemptError. If // that invariant ever breaks, surface the raw error with // the current attempt count rather than panicking. slog.Error("bot.fastapi.attempt", "request_id", req.RequestID, "attempt", attempt, "latency_ms", latencyMs, "outcome", "nonretryable_failure", "error", err.Error(), ) return ChatCallResult{}, &attemptError{ sentinel: nil, attempts: attempt, cause: err, } } logAttemptOutcome(req.RequestID, attempt, latencyMs, classified) if !classified.retryable { return ChatCallResult{}, classified } lastErr = classified lastResponseBody = classified.responseBody lastStatus = classified.responseStatus } // All attempts exhausted as retryable failures. lastErrString := "" if lastErr != nil { lastErrString = lastErr.Error() } slog.Error("bot.fastapi.exhausted", "request_id", req.RequestID, "attempts", fastAPIMaxAttempts, "last_status", lastStatus, "last_error", lastErrString, ) if lastErr != nil { return ChatCallResult{}, &attemptError{ sentinel: nil, attempts: fastAPIMaxAttempts, cause: lastErr, responseBody: lastResponseBody, responseStatus: lastStatus, } } return ChatCallResult{}, &attemptError{ sentinel: nil, attempts: fastAPIMaxAttempts, cause: errors.New("bot: chat retries exhausted"), } } // logAttemptOutcome emits a single bot.fastapi.attempt slog record // summarizing one *attemptError. WARN for retryable failures, ERROR // otherwise. status_code is omitted when the failure occurred before // receiving an HTTP response (transport error, parent ctx canceled). func logAttemptOutcome(requestID string, attempt int, latencyMs int64, classified *attemptError) { outcome := "retryable_failure" if !classified.retryable { outcome = "nonretryable_failure" } cause := "" if classified.cause != nil { cause = classified.cause.Error() } attrs := []any{ "request_id", requestID, "attempt", attempt, "latency_ms", latencyMs, "outcome", outcome, "error", cause, } if classified.responseStatus != 0 { attrs = append(attrs, "status_code", classified.responseStatus) } if classified.retryable { slog.Warn("bot.fastapi.attempt", attrs...) return } slog.Error("bot.fastapi.attempt", attrs...) } // attemptChat performs one HTTP attempt and classifies the outcome. // Returns nil err + ChatCallResult on success; otherwise returns a // non-nil *attemptError carrying the retryable flag. func (c *FastAPIClient) attemptChat(parent context.Context, url string, body []byte, timeout time.Duration, sentAt time.Time, attempt int) (ChatCallResult, error) { attemptCtx, cancel := context.WithTimeout(parent, timeout) defer cancel() httpReq, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return ChatCallResult{}, &attemptError{ sentinel: ErrAgentInvalidResponse, attempts: attempt, cause: err, retryable: false, } } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set(apiKeyHeader, c.cfg.APIKey) resp, err := c.httpClient.Do(httpReq) receivedAt := time.Now() if err != nil { // Distinguish parent ctx cancellation from a transient network // failure. Parent-ctx-canceled aborts the loop; a per-attempt // timeout (attemptCtx done while parent still alive) is a // retryable transport failure per plan §7. if parent.Err() != nil { return ChatCallResult{}, &attemptError{ sentinel: nil, attempts: attempt, cause: parent.Err(), retryable: false, } } return ChatCallResult{}, &attemptError{ sentinel: nil, attempts: attempt, cause: err, retryable: true, } } defer resp.Body.Close() respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { return ChatCallResult{}, &attemptError{ sentinel: nil, attempts: attempt, cause: fmt.Errorf("read response body: %w", readErr), retryable: true, } } if resp.StatusCode != http.StatusOK { return ChatCallResult{}, classifyNon200(resp.StatusCode, respBody, attempt) } return classify200(respBody, sentAt, receivedAt, attempt) } // classifyNon200 builds the *attemptError for a non-2xx response. The // returned error is always *attemptError; the caller wraps it in a // dummy non-nil error sentinel so the calling code path treats it as // "attempt failed". func classifyNon200(status int, body []byte, attempt int) *attemptError { retryable := status == http.StatusTooManyRequests || status == http.StatusInternalServerError || status == http.StatusBadGateway || status == http.StatusServiceUnavailable || status == http.StatusGatewayTimeout return &attemptError{ sentinel: nil, attempts: attempt, cause: fmt.Errorf("FastAPI returned status %d: %s", status, truncateBody(body)), responseStatus: status, responseBody: body, retryable: retryable, } } // classify200 parses a 200 response body and applies plan §7's // success-path validation rules. Returns ChatCallResult + nil on // success; otherwise *attemptError with retryable=false (plan §7 // mandates that all 200-classified failures are not retried). func classify200(body []byte, sentAt, receivedAt time.Time, attempt int) (ChatCallResult, error) { var resp ChatResponse if err := json.Unmarshal(body, &resp); err != nil { return ChatCallResult{}, &attemptError{ sentinel: ErrAgentInvalidResponse, attempts: attempt, cause: fmt.Errorf("parse response: %w", err), responseStatus: http.StatusOK, responseBody: body, retryable: false, } } if resp.Status == "error" { code, message := errorPayloadFields(resp.Error) return ChatCallResult{}, &attemptError{ sentinel: ErrAgentApplicationError, attempts: attempt, cause: fmt.Errorf("FastAPI status=error: code=%s message=%s", code, message), responseStatus: http.StatusOK, responseBody: body, retryable: false, } } if resp.Answer == nil || strings.TrimSpace(resp.Answer.Text) == "" { return ChatCallResult{}, &attemptError{ sentinel: ErrAgentMissingAnswer, attempts: attempt, cause: errors.New("FastAPI 200 response has empty/null answer.text"), responseStatus: http.StatusOK, responseBody: body, retryable: false, } } return ChatCallResult{ Response: &resp, SentAt: sentAt, ReceivedAt: receivedAt, AttemptCount: attempt, }, nil } // errorPayloadFields safely extracts (code, message) from a *ErrorPayload // pointer that may be nil. Used so the wrapped error message always // surfaces something concrete instead of a Go nil-pointer panic. func errorPayloadFields(p *ErrorPayload) (string, string) { if p == nil { return "", "" } return p.Code, p.Message } // truncateBody returns at most 256 bytes of the supplied response body // for inclusion in error messages. Avoids dumping multi-megabyte HTML // error pages into logs. func truncateBody(b []byte) string { const max = 256 if len(b) <= max { return string(b) } return string(b[:max]) + "..." } // Health performs the /health smoke-check defined in plan §7. Returns // nil on success; ErrHealthStatusNotOK or ErrHealthMissingService on // the documented failure modes; a wrapped non-sentinel error on // transport / parse failures. func (c *FastAPIClient) Health(ctx context.Context) error { url := c.baseURL + fastAPIHealthPath req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return fmt.Errorf("bot.Health: build request: %w", err) } req.Header.Set(apiKeyHeader, c.cfg.APIKey) resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("bot.Health: do: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("bot.Health: read body: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("bot.Health: status %d: %s", resp.StatusCode, truncateBody(body)) } var health HealthResponse if err := json.Unmarshal(body, &health); err != nil { return fmt.Errorf("bot.Health: parse body: %w", err) } if health.Status != "ok" { return fmt.Errorf("%w: got %q", ErrHealthStatusNotOK, health.Status) } if health.Service == "" || health.Version == "" { return fmt.Errorf("%w: service=%q version=%q", ErrHealthMissingService, health.Service, health.Version) } return nil } // attemptError is the single error type Chat surfaces. It implements // AttemptCounter and supports errors.Is on the wrapped sentinel + the // underlying cause so callers can switch on either layer. type attemptError struct { sentinel error attempts int cause error responseStatus int responseBody []byte retryable bool } // Error renders a single-line summary that includes the attempt count // and either the sentinel name or the cause message — whichever is // available. func (e *attemptError) Error() string { parts := []string{fmt.Sprintf("attempts=%d", e.attempts)} if e.sentinel != nil { parts = append(parts, e.sentinel.Error()) } if e.cause != nil { parts = append(parts, e.cause.Error()) } if e.responseStatus != 0 { parts = append(parts, fmt.Sprintf("status=%d", e.responseStatus)) } return "bot.Chat: " + strings.Join(parts, "; ") } // Is supports errors.Is(err, sentinel) directly so callers do not have // to navigate the cause chain manually. The custom Is method also // dispatches to the wrapped cause so errors.Is(err, context.Canceled) // works without a separate Unwrap method on this type. func (e *attemptError) Is(target error) bool { if e.sentinel != nil && errors.Is(e.sentinel, target) { return true } if e.cause != nil && errors.Is(e.cause, target) { return true } return false } // AttemptCount satisfies the AttemptCounter interface so the service // layer can persist this scalar in bot_turns.error per plan §6. func (e *attemptError) AttemptCount() int { return e.attempts }