Ctrl K
ComunidadGuías…

internal/gateway/attempts.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package gateway

import (
	"context"
	"errors"
	"net/http"
	"strings"
	"sync"
	"time"

	"github.com/enterpilot/gomodel/internal/core"
)

const (
	AttemptKindPrimary  = "primary"
	AttemptKindFailover = "failover"
	AttemptKindRetry    = "retry"
)

type attemptRecorderKey struct{}

type attemptObserverKey struct{}

// AttemptObserver is invoked after a failed provider attempt is recorded, so the
// audit/live layer can surface it before the overall request finishes (e.g. a
// failed primary while failover is still in flight).
type AttemptObserver func()

// WithAttemptObserver registers an observer notified after each failed attempt.
// It is independent of (and additive to) the attempt recorder.
func WithAttemptObserver(ctx context.Context, observe AttemptObserver) context.Context {
	if ctx == nil {
		ctx = context.Background()
	}
	if observe == nil {
		return ctx
	}
	return context.WithValue(ctx, attemptObserverKey{}, observe)
}

func attemptObserverFromContext(ctx context.Context) AttemptObserver {
	if ctx == nil {
		return nil
	}
	observe, _ := ctx.Value(attemptObserverKey{}).(AttemptObserver)
	return observe
}

// ProviderAttempt describes one external provider call made while serving a
// logical request. It is intentionally storage-agnostic; server/audit layers
// decide how to persist it.
type ProviderAttempt struct {
	Seq          int
	Kind         string
	ProviderType string
	ProviderName string
	Model        string
	StatusCode   int
	Success      bool
	ErrorType    string
	ErrorCode    string
	ErrorMessage string
	StartedAt    time.Time
	DurationNs   int64
	// ResponseBody and ResponseHeaders hold the raw upstream error response for
	// a failed attempt. They are persisted only when audit body/header logging
	// is enabled; the audit layer parses and redacts them before storage.
	ResponseBody    []byte
	ResponseHeaders http.Header
}

type AttemptRecorder struct {
	mu       sync.Mutex
	attempts []ProviderAttempt
}

// WithAttemptRecorder ensures ctx carries a request-scoped attempt recorder.
func WithAttemptRecorder(ctx context.Context) context.Context {
	if ctx == nil {
		ctx = context.Background()
	}
	if AttemptRecorderFromContext(ctx) != nil {
		return ctx
	}
	return context.WithValue(ctx, attemptRecorderKey{}, &AttemptRecorder{})
}

func AttemptRecorderFromContext(ctx context.Context) *AttemptRecorder {
	if ctx == nil {
		return nil
	}
	recorder, _ := ctx.Value(attemptRecorderKey{}).(*AttemptRecorder)
	return recorder
}

func AttemptsFromContext(ctx context.Context) []ProviderAttempt {
	recorder := AttemptRecorderFromContext(ctx)
	if recorder == nil {
		return nil
	}
	recorder.mu.Lock()
	defer recorder.mu.Unlock()
	if len(recorder.attempts) == 0 {
		return nil
	}
	attempts := make([]ProviderAttempt, len(recorder.attempts))
	copy(attempts, recorder.attempts)
	return attempts
}

func recordProviderAttempt(ctx context.Context, attempt ProviderAttempt) {
	recorder := AttemptRecorderFromContext(ctx)
	if recorder == nil {
		return
	}
	attempt.Kind = normalizeProviderAttemptKind(attempt.Kind)
	if attempt.Kind == "" {
		return
	}
	attempt.ProviderType = strings.TrimSpace(attempt.ProviderType)
	attempt.ProviderName = strings.TrimSpace(attempt.ProviderName)
	attempt.Model = strings.TrimSpace(attempt.Model)
	attempt.ErrorType = strings.TrimSpace(attempt.ErrorType)
	attempt.ErrorCode = strings.TrimSpace(attempt.ErrorCode)
	attempt.ErrorMessage = strings.TrimSpace(attempt.ErrorMessage)

	recorder.mu.Lock()
	if attempt.Seq <= 0 {
		attempt.Seq = len(recorder.attempts) + 1
	}
	recorder.attempts = append(recorder.attempts, attempt)
	recorder.mu.Unlock()

	// Surface failures live as they happen (a failed primary/attempt before
	// failover completes). Successful attempts take the normal end-of-request
	// path, so the hot path stays free of extra live publishes.
	if !attempt.Success {
		if observe := attemptObserverFromContext(ctx); observe != nil {
			observe()
		}
	}
}

func normalizeProviderAttemptKind(kind string) string {
	switch strings.ToLower(strings.TrimSpace(kind)) {
	case AttemptKindPrimary:
		return AttemptKindPrimary
	case AttemptKindFailover:
		return AttemptKindFailover
	case AttemptKindRetry:
		return AttemptKindRetry
	default:
		return ""
	}
}

func providerAttemptFromResult(kind, providerType, providerName, model string, started time.Time, err error) ProviderAttempt {
	attempt := ProviderAttempt{
		Kind:         kind,
		ProviderType: providerType,
		ProviderName: providerName,
		Model:        model,
		StartedAt:    started,
		DurationNs:   time.Since(started).Nanoseconds(),
		Success:      err == nil,
	}
	if err == nil {
		attempt.StatusCode = http.StatusOK
		return attempt
	}

	var gatewayErr *core.GatewayError
	if errors.As(err, &gatewayErr) && gatewayErr != nil {
		attempt.StatusCode = gatewayErr.HTTPStatusCode()
		attempt.ErrorType = string(gatewayErr.Type)
		attempt.ErrorMessage = gatewayErr.Message
		if gatewayErr.Code != nil {
			attempt.ErrorCode = *gatewayErr.Code
		}
		attempt.ResponseBody = gatewayErr.ResponseBody
		attempt.ResponseHeaders = gatewayErr.ResponseHeaders
		return attempt
	}

	attempt.StatusCode = http.StatusInternalServerError
	attempt.ErrorType = string(core.ErrorTypeProvider)
	attempt.ErrorMessage = err.Error()
	return attempt
}