internal/gateway/attempts.go
Rama main · réplica de lectura del repositorio original.
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
}