| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322 |
- /*
- Copyright © The ESO Authors
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
- https://www.apache.org/licenses/LICENSE-2.0
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
- */
- package grpc
- import (
- "context"
- cryptorand "crypto/rand"
- "encoding/binary"
- "errors"
- "fmt"
- "math"
- "sync"
- "time"
- "google.golang.org/grpc/codes"
- "google.golang.org/grpc/status"
- )
- // RetryConfig configures retry behavior.
- type RetryConfig struct {
- // MaxAttempts is the maximum number of retry attempts.
- MaxAttempts int
- // InitialBackoff is the initial backoff duration.
- InitialBackoff time.Duration
- // MaxBackoff is the maximum backoff duration.
- MaxBackoff time.Duration
- // BackoffMultiplier is the multiplier for exponential backoff.
- BackoffMultiplier float64
- // Jitter adds randomness to backoff to prevent thundering herd.
- Jitter bool
- }
- // DefaultRetryConfig returns sensible defaults for retry behavior.
- func DefaultRetryConfig() RetryConfig {
- return RetryConfig{
- MaxAttempts: 3,
- InitialBackoff: 100 * time.Millisecond,
- MaxBackoff: 10 * time.Second,
- BackoffMultiplier: 2.0,
- Jitter: true,
- }
- }
- // CircuitBreakerConfig configures circuit breaker behavior.
- type CircuitBreakerConfig struct {
- // MaxFailures is the number of consecutive failures before opening.
- MaxFailures int
- // Timeout is how long to wait in open state before trying again.
- Timeout time.Duration
- // HalfOpenMaxRequests is max requests allowed in half-open state.
- HalfOpenMaxRequests int
- }
- // DefaultCircuitBreakerConfig returns sensible defaults.
- func DefaultCircuitBreakerConfig() CircuitBreakerConfig {
- return CircuitBreakerConfig{
- MaxFailures: 5,
- Timeout: 30 * time.Second,
- HalfOpenMaxRequests: 1,
- }
- }
- // CircuitState represents the state of a circuit breaker.
- type CircuitState int
- const (
- // StateClosed means the circuit is closed and requests flow normally.
- StateClosed CircuitState = iota
- // StateOpen means the circuit is open and requests fail fast.
- StateOpen
- // StateHalfOpen means the circuit is testing if the service has recovered.
- StateHalfOpen
- )
- // CircuitBreaker implements the circuit breaker pattern.
- type CircuitBreaker struct {
- mu sync.RWMutex
- state CircuitState
- failures int
- lastFailureTime time.Time
- halfOpenReqs int
- config CircuitBreakerConfig
- }
- // NewCircuitBreaker creates a new circuit breaker.
- func NewCircuitBreaker(config CircuitBreakerConfig) *CircuitBreaker {
- return &CircuitBreaker{
- state: StateClosed,
- config: config,
- }
- }
- // Call executes the given function with circuit breaker protection.
- func (cb *CircuitBreaker) Call(_ context.Context, fn func() error) error {
- // Check if we should allow the request.
- if err := cb.beforeRequest(); err != nil {
- return err
- }
- // Execute the function.
- err := fn()
- // Record the result.
- cb.afterRequest(err)
- return err
- }
- // beforeRequest checks if the request should be allowed.
- func (cb *CircuitBreaker) beforeRequest() error {
- cb.mu.Lock()
- defer cb.mu.Unlock()
- switch cb.state {
- case StateClosed:
- return nil
- case StateOpen:
- // Check if timeout has elapsed
- if time.Since(cb.lastFailureTime) > cb.config.Timeout {
- cb.state = StateHalfOpen
- cb.halfOpenReqs = 0
- return nil
- }
- return fmt.Errorf("circuit breaker is open")
- case StateHalfOpen:
- // Allow limited requests in half-open state
- if cb.halfOpenReqs >= cb.config.HalfOpenMaxRequests {
- return fmt.Errorf("circuit breaker is half-open, max requests reached")
- }
- cb.halfOpenReqs++
- return nil
- default:
- return fmt.Errorf("unknown circuit breaker state")
- }
- }
- // afterRequest records the result of a request.
- func (cb *CircuitBreaker) afterRequest(err error) {
- cb.mu.Lock()
- defer cb.mu.Unlock()
- if err == nil {
- // Success
- cb.onSuccess()
- } else {
- // Failure
- cb.onFailure()
- }
- }
- // onSuccess handles a successful request.
- func (cb *CircuitBreaker) onSuccess() {
- switch cb.state {
- case StateClosed:
- cb.failures = 0
- case StateOpen:
- // Requests should not reach onSuccess while open, keep state unchanged.
- case StateHalfOpen:
- // Success in half-open state means we can close the circuit
- cb.state = StateClosed
- cb.failures = 0
- cb.halfOpenReqs = 0
- }
- }
- // onFailure handles a failed request.
- func (cb *CircuitBreaker) onFailure() {
- cb.failures++
- cb.lastFailureTime = time.Now()
- switch cb.state {
- case StateClosed:
- if cb.failures >= cb.config.MaxFailures {
- cb.state = StateOpen
- }
- case StateOpen:
- // Keep tracking failure timing while the circuit remains open.
- case StateHalfOpen:
- // Failure in half-open state means we go back to open
- cb.state = StateOpen
- cb.halfOpenReqs = 0
- }
- }
- // State returns the current state of the circuit breaker.
- func (cb *CircuitBreaker) State() CircuitState {
- cb.mu.RLock()
- defer cb.mu.RUnlock()
- return cb.state
- }
- // RetryableFunc is a function that can be retried.
- type RetryableFunc func(ctx context.Context, attempt int) error
- // WithRetry executes a function with exponential backoff retry logic.
- func WithRetry(ctx context.Context, config RetryConfig, fn RetryableFunc) error {
- var lastErr error
- for attempt := 0; attempt < config.MaxAttempts; attempt++ {
- // Execute the function
- err := fn(ctx, attempt)
- if err == nil {
- return nil
- }
- lastErr = err
- // Check if error is retryable
- if !isRetryable(err) {
- return err
- }
- // Last attempt, don't wait
- if attempt == config.MaxAttempts-1 {
- break
- }
- // Calculate backoff
- backoff := calculateBackoff(attempt, config)
- // Wait with context cancellation support
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-time.After(backoff):
- // Continue to next attempt
- }
- }
- return fmt.Errorf("max retry attempts (%d) exceeded: %w", config.MaxAttempts, lastErr)
- }
- // isRetryable determines if an error should trigger a retry.
- func isRetryable(err error) bool {
- // Check gRPC status codes
- st, ok := status.FromError(err)
- if ok {
- switch st.Code() {
- case codes.Unavailable,
- codes.DeadlineExceeded,
- codes.ResourceExhausted,
- codes.Aborted:
- return true
- case codes.OK,
- codes.Canceled,
- codes.InvalidArgument,
- codes.NotFound,
- codes.AlreadyExists,
- codes.PermissionDenied,
- codes.Unauthenticated,
- codes.FailedPrecondition,
- codes.OutOfRange,
- codes.Unimplemented:
- return false
- case codes.Unknown,
- codes.Internal,
- codes.DataLoss:
- // Retry transient or ambiguous failures.
- return true
- }
- }
- // For non-gRPC errors, retry network-related issues.
- // Context errors should not be retried.
- if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
- return false
- }
- // Default: retry.
- return true
- }
- // calculateBackoff calculates the backoff duration for a given attempt.
- func calculateBackoff(attempt int, config RetryConfig) time.Duration {
- // Exponential backoff: initialBackoff * (multiplier ^ attempt).
- backoff := float64(config.InitialBackoff) * math.Pow(config.BackoffMultiplier, float64(attempt))
- // Apply max backoff.
- if backoff > float64(config.MaxBackoff) {
- backoff = float64(config.MaxBackoff)
- }
- // Add jitter if enabled.
- if config.Jitter {
- // Add random jitter between 0 and 25% of backoff.
- jitter := randomFloat64() * backoff * 0.25
- backoff += jitter
- }
- return time.Duration(backoff)
- }
- func randomFloat64() float64 {
- var buf [8]byte
- if _, err := cryptorand.Read(buf[:]); err != nil {
- return 0
- }
- return float64(binary.BigEndian.Uint64(buf[:])) / float64(^uint64(0))
- }
|