This commit is contained in:
Tomas Dvorak
2025-11-02 01:04:02 +01:00
parent ac886502e0
commit b9cea0cd77
153 changed files with 43713 additions and 1700 deletions
+139
View File
@@ -0,0 +1,139 @@
package circuitbreaker
import (
"errors"
"sync"
"time"
)
var (
// ErrCircuitOpen is returned when the circuit breaker is open
ErrCircuitOpen = errors.New("circuit breaker is open")
)
// State represents the circuit breaker state
type State int
const (
StateClosed State = iota
StateOpen
StateHalfOpen
)
// CircuitBreaker implements the circuit breaker pattern for external service calls
type CircuitBreaker struct {
mu sync.RWMutex
state State
failureCount int
successCount int
lastFailureTime time.Time
lastSuccessTime time.Time
maxFailures int
timeout time.Duration
halfOpenAttempts int
}
// New creates a new circuit breaker
// maxFailures: number of failures before opening the circuit
// timeout: how long to wait before attempting to close the circuit
func New(maxFailures int, timeout time.Duration) *CircuitBreaker {
return &CircuitBreaker{
state: StateClosed,
maxFailures: maxFailures,
timeout: timeout,
halfOpenAttempts: 3, // Allow 3 requests in half-open state
}
}
// Call executes the function if the circuit is closed
func (cb *CircuitBreaker) Call(fn func() error) error {
cb.mu.Lock()
// Check if we should transition from open to half-open
if cb.state == StateOpen && time.Since(cb.lastFailureTime) > cb.timeout {
cb.state = StateHalfOpen
cb.successCount = 0
}
// Reject if circuit is open
if cb.state == StateOpen {
cb.mu.Unlock()
return ErrCircuitOpen
}
// Allow limited attempts in half-open state
if cb.state == StateHalfOpen && cb.successCount >= cb.halfOpenAttempts {
cb.mu.Unlock()
return ErrCircuitOpen
}
cb.mu.Unlock()
// Execute the function
err := fn()
cb.mu.Lock()
defer cb.mu.Unlock()
if err != nil {
cb.onFailure()
return err
}
cb.onSuccess()
return nil
}
// onFailure is called when a request fails
func (cb *CircuitBreaker) onFailure() {
cb.failureCount++
cb.lastFailureTime = time.Now()
if cb.state == StateHalfOpen {
// Immediately open if failure in half-open state
cb.state = StateOpen
cb.successCount = 0
return
}
if cb.failureCount >= cb.maxFailures {
cb.state = StateOpen
}
}
// onSuccess is called when a request succeeds
func (cb *CircuitBreaker) onSuccess() {
cb.lastSuccessTime = time.Now()
if cb.state == StateHalfOpen {
cb.successCount++
if cb.successCount >= cb.halfOpenAttempts {
// Close the circuit after successful attempts
cb.state = StateClosed
cb.failureCount = 0
cb.successCount = 0
}
return
}
if cb.state == StateClosed {
// Reset failure count on success
cb.failureCount = 0
}
}
// GetState returns the current state of the circuit breaker
func (cb *CircuitBreaker) GetState() State {
cb.mu.RLock()
defer cb.mu.RUnlock()
return cb.state
}
// Reset manually resets the circuit breaker to closed state
func (cb *CircuitBreaker) Reset() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.state = StateClosed
cb.failureCount = 0
cb.successCount = 0
}
+11
View File
@@ -149,6 +149,17 @@ func MigrateDB(db *gorm.DB) error {
&models.FileUsage{},
&models.ShortLink{},
&models.LinkClick{},
&models.Comment{},
&models.CommentReaction{},
&models.CommentBan{},
&models.UnbanRequest{},
&models.CommentReport{},
&models.UserProfile{},
&models.PointsTransaction{},
&models.Achievement{},
&models.UserAchievement{},
&models.RewardItem{},
&models.RewardRedemption{},
)
}
+14 -1
View File
@@ -585,6 +585,19 @@ func (s *emailService) buildDialerAndFrom() (*mail.Dialer, string, string) {
}
}
// Fallback FromName to club name when not configured
if strings.TrimSpace(effFromName) == "" && s.db != nil {
var set models.Settings
if err := s.db.First(&set).Error; err == nil {
if name := strings.TrimSpace(set.ClubName); name != "" {
effFromName = name
}
}
}
if strings.TrimSpace(effFromName) == "" {
effFromName = "Fotbal Club"
}
d := mail.NewDialer(effHost, effPort, effUser, effPass)
if effEncryption == "ssl" {
d.SSL = true
@@ -1202,7 +1215,7 @@ func (s *emailService) SendNewsletter(data *NewsletterData) error {
m := mail.NewMessage()
// Build From with sanitized values; prefer emailData overrides if provided
rawName := effFromName + " Newsletter"
rawName := effFromName
if strings.TrimSpace(emailData.FromName) != "" {
rawName = emailData.FromName
}
+76
View File
@@ -0,0 +1,76 @@
package httpclient
import (
"crypto/tls"
"net"
"net/http"
"time"
)
// DefaultClient returns a production-ready HTTP client with reasonable timeouts
// and connection pooling to prevent resource exhaustion
func DefaultClient() *http.Client {
return &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
// Connection pool settings
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 50,
IdleConnTimeout: 90 * time.Second,
// Timeouts
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 15 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
// HTTP/2 support
ForceAttemptHTTP2: true,
// TLS settings
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
},
}
}
// FastClient returns a client optimized for fast internal API calls
func FastClient() *http.Client {
return &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 50,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 30 * time.Second,
DialContext: (&net.Dialer{
Timeout: 2 * time.Second,
KeepAlive: 15 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 4 * time.Second,
},
}
}
// SlowClient returns a client for potentially slow external APIs (e.g., AI, analytics)
func SlowClient() *http.Client {
return &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 120 * time.Second,
DialContext: (&net.Dialer{
Timeout: 15 * time.Second,
KeepAlive: 60 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 15 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
},
}
}