- Added configuration options for Stalwart hosted mail in .env.example. - Updated Docker Compose to include Stalwart service with health checks. - Introduced new API endpoints for managing mail domains and migration projects. - Enhanced Authentik blueprints for user enrollment and post-migration security. - Updated OAuth handling for Google and Microsoft migration processes. - Improved error handling and response structures in the mail API. - Added integration tests for email claiming and migration workflows.
187 lines
4.1 KiB
Go
187 lines
4.1 KiB
Go
package migration
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/observability"
|
|
)
|
|
|
|
const (
|
|
defaultRateLimitMaxRetries = 6
|
|
defaultRateLimitBaseDelay = 2 * time.Second
|
|
defaultRateLimitMaxDelay = 2 * time.Minute
|
|
)
|
|
|
|
// RateLimitConfig controls exponential backoff for migration provider API calls.
|
|
type RateLimitConfig struct {
|
|
MaxRetries int
|
|
BaseDelay time.Duration
|
|
MaxDelay time.Duration
|
|
}
|
|
|
|
// RateLimitError is returned when a provider keeps responding with HTTP 429
|
|
// after all configured retries are exhausted.
|
|
type RateLimitError struct {
|
|
Cause error
|
|
RetryAfter time.Duration
|
|
}
|
|
|
|
func (e *RateLimitError) Error() string {
|
|
if e == nil || e.Cause == nil {
|
|
return "migration api rate limited"
|
|
}
|
|
return e.Cause.Error()
|
|
}
|
|
|
|
func (e *RateLimitError) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.Cause
|
|
}
|
|
|
|
// IsRateLimitError reports whether err is a terminal migration rate-limit error.
|
|
func IsRateLimitError(err error) bool {
|
|
var rl *RateLimitError
|
|
return errors.As(err, &rl)
|
|
}
|
|
|
|
var (
|
|
rateLimitMu sync.RWMutex
|
|
rateLimitConfig = RateLimitConfig{
|
|
MaxRetries: defaultRateLimitMaxRetries,
|
|
BaseDelay: defaultRateLimitBaseDelay,
|
|
MaxDelay: defaultRateLimitMaxDelay,
|
|
}
|
|
)
|
|
|
|
// ConfigureRateLimit sets package-wide retry settings for migration HTTP calls.
|
|
func ConfigureRateLimit(cfg RateLimitConfig) {
|
|
rateLimitMu.Lock()
|
|
defer rateLimitMu.Unlock()
|
|
if cfg.MaxRetries > 0 {
|
|
rateLimitConfig.MaxRetries = cfg.MaxRetries
|
|
}
|
|
if cfg.BaseDelay > 0 {
|
|
rateLimitConfig.BaseDelay = cfg.BaseDelay
|
|
}
|
|
if cfg.MaxDelay > 0 {
|
|
rateLimitConfig.MaxDelay = cfg.MaxDelay
|
|
}
|
|
}
|
|
|
|
func currentRateLimitConfig() RateLimitConfig {
|
|
rateLimitMu.RLock()
|
|
defer rateLimitMu.RUnlock()
|
|
return rateLimitConfig
|
|
}
|
|
|
|
func parseRetryAfter(v string) time.Duration {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return 0
|
|
}
|
|
if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
|
|
return time.Duration(secs) * time.Second
|
|
}
|
|
if t, err := http.ParseTime(v); err == nil {
|
|
d := time.Until(t)
|
|
if d < 0 {
|
|
return 0
|
|
}
|
|
return d
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func rateLimitDelay(attempt int, retryAfter time.Duration) time.Duration {
|
|
cfg := currentRateLimitConfig()
|
|
delay := cfg.BaseDelay
|
|
for i := 1; i < attempt; i++ {
|
|
if delay >= cfg.MaxDelay {
|
|
delay = cfg.MaxDelay
|
|
break
|
|
}
|
|
delay *= 2
|
|
}
|
|
if retryAfter > delay {
|
|
delay = retryAfter
|
|
}
|
|
if delay > cfg.MaxDelay {
|
|
delay = cfg.MaxDelay
|
|
}
|
|
return delay
|
|
}
|
|
|
|
func migrationDo(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
|
|
if client == nil {
|
|
client = migrationHTTPClient()
|
|
}
|
|
cfg := currentRateLimitConfig()
|
|
var lastErr error
|
|
var lastRetryAfter time.Duration
|
|
|
|
for attempt := 0; attempt <= cfg.MaxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
observability.IncMigrationRateLimitRetry()
|
|
delay := rateLimitDelay(attempt, lastRetryAfter)
|
|
slog.Default().Warn(
|
|
"migration api rate limited, backing off",
|
|
"component", "migration-http",
|
|
"attempt", attempt,
|
|
"max_retries", cfg.MaxRetries,
|
|
"delay", delay.String(),
|
|
"method", req.Method,
|
|
"host", req.URL.Host,
|
|
"path", req.URL.Path,
|
|
)
|
|
timer := time.NewTimer(delay)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return nil, ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
|
|
cloned := req.Clone(ctx)
|
|
resp, err := client.Do(cloned)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
lastRetryAfter = parseRetryAfter(resp.Header.Get("Retry-After"))
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
lastErr = fmt.Errorf("api rate limited (429): %s", strings.TrimSpace(string(raw)))
|
|
if attempt >= cfg.MaxRetries {
|
|
return nil, &RateLimitError{Cause: lastErr, RetryAfter: lastRetryAfter}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("api %s: %s", resp.Status, strings.TrimSpace(string(raw)))
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
if lastErr == nil {
|
|
lastErr = errors.New("migration api rate limited")
|
|
}
|
|
return nil, &RateLimitError{Cause: lastErr, RetryAfter: lastRetryAfter}
|
|
}
|