Files
Sportstime/SportsTime/Core/Services/RateLimiter.swift
Trey t 92d808caf5 Add Stadium Progress system and themed loading spinners
Stadium Progress & Achievements:
- Add StadiumVisit and Achievement SwiftData models
- Create Progress tab with interactive map view
- Implement photo-based visit import with GPS/date matching
- Add achievement badges (count-based, regional, journey)
- Create shareable progress cards for social media
- Add canonical data infrastructure (stadium identities, team aliases)
- Implement score resolution from free APIs (MLB, NBA, NHL stats)

UI Improvements:
- Add ThemedSpinner and ThemedSpinnerCompact components
- Replace all ProgressView() with themed spinners throughout app
- Fix sport selection state not persisting when navigating away

Bug Fixes:
- Fix Coast to Coast trips showing only 1 city (validation issue)
- Fix stadium progress showing 0/0 (filtering issue)
- Remove "Stadium Quest" title from progress view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:20:03 -06:00

209 lines
6.9 KiB
Swift

//
// RateLimiter.swift
// SportsTime
//
// Rate limiting for API providers to respect their rate limits.
//
import Foundation
// MARK: - Rate Limiter
/// Per-provider rate limiting to avoid hitting API limits
actor RateLimiter {
// MARK: - Types
struct ProviderConfig {
let name: String
let minInterval: TimeInterval // Minimum time between requests
let burstLimit: Int // Max requests in burst window
let burstWindow: TimeInterval // Window for burst counting
}
// MARK: - Properties
private var lastRequestTimes: [String: Date] = [:]
private var requestCounts: [String: [Date]] = [:]
private var configs: [String: ProviderConfig] = [:]
// MARK: - Default Configurations
/// Default provider rate limit configurations
private static let defaultConfigs: [ProviderConfig] = [
ProviderConfig(name: "mlb_stats", minInterval: 0.1, burstLimit: 30, burstWindow: 60), // 10 req/sec
ProviderConfig(name: "nhl_stats", minInterval: 0.2, burstLimit: 20, burstWindow: 60), // 5 req/sec
ProviderConfig(name: "nba_stats", minInterval: 0.5, burstLimit: 10, burstWindow: 60), // 2 req/sec
ProviderConfig(name: "espn", minInterval: 1.0, burstLimit: 30, burstWindow: 60), // 1 req/sec
ProviderConfig(name: "sports_reference", minInterval: 3.0, burstLimit: 10, burstWindow: 60) // 1 req/3 sec
]
// MARK: - Singleton
static let shared = RateLimiter()
private init() {
// Load default configs
for config in Self.defaultConfigs {
configs[config.name] = config
}
}
// MARK: - Configuration
/// Configure rate limiting for a provider
func configureProvider(_ config: ProviderConfig) {
configs[config.name] = config
}
// MARK: - Rate Limiting
/// Wait if needed to respect rate limits for a provider
/// Returns immediately if rate limit allows, otherwise sleeps until allowed
func waitIfNeeded(for provider: String) async {
let config = configs[provider] ?? ProviderConfig(
name: provider,
minInterval: 1.0,
burstLimit: 60,
burstWindow: 60
)
await enforceMinInterval(for: provider, interval: config.minInterval)
await enforceBurstLimit(for: provider, limit: config.burstLimit, window: config.burstWindow)
recordRequest(for: provider)
}
/// Check if a request can be made without waiting
func canMakeRequest(for provider: String) -> Bool {
let config = configs[provider] ?? ProviderConfig(
name: provider,
minInterval: 1.0,
burstLimit: 60,
burstWindow: 60
)
// Check min interval
if let lastRequest = lastRequestTimes[provider] {
let elapsed = Date().timeIntervalSince(lastRequest)
if elapsed < config.minInterval {
return false
}
}
// Check burst limit
let now = Date()
let windowStart = now.addingTimeInterval(-config.burstWindow)
if let requests = requestCounts[provider] {
let recentRequests = requests.filter { $0 > windowStart }
if recentRequests.count >= config.burstLimit {
return false
}
}
return true
}
/// Get estimated wait time until next request is allowed
func estimatedWaitTime(for provider: String) -> TimeInterval {
let config = configs[provider] ?? ProviderConfig(
name: provider,
minInterval: 1.0,
burstLimit: 60,
burstWindow: 60
)
var maxWait: TimeInterval = 0
// Check min interval wait
if let lastRequest = lastRequestTimes[provider] {
let elapsed = Date().timeIntervalSince(lastRequest)
if elapsed < config.minInterval {
maxWait = max(maxWait, config.minInterval - elapsed)
}
}
// Check burst limit wait
let now = Date()
let windowStart = now.addingTimeInterval(-config.burstWindow)
if let requests = requestCounts[provider] {
let recentRequests = requests.filter { $0 > windowStart }.sorted()
if recentRequests.count >= config.burstLimit {
// Need to wait until oldest request falls out of window
if let oldestInWindow = recentRequests.first {
let waitUntil = oldestInWindow.addingTimeInterval(config.burstWindow)
let wait = waitUntil.timeIntervalSince(now)
maxWait = max(maxWait, wait)
}
}
}
return maxWait
}
/// Reset rate limit tracking for a provider
func reset(for provider: String) {
lastRequestTimes.removeValue(forKey: provider)
requestCounts.removeValue(forKey: provider)
}
/// Reset all rate limit tracking
func resetAll() {
lastRequestTimes.removeAll()
requestCounts.removeAll()
}
// MARK: - Private Helpers
private func enforceMinInterval(for provider: String, interval: TimeInterval) async {
if let lastRequest = lastRequestTimes[provider] {
let elapsed = Date().timeIntervalSince(lastRequest)
if elapsed < interval {
let waitTime = interval - elapsed
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
}
}
}
private func enforceBurstLimit(for provider: String, limit: Int, window: TimeInterval) async {
let now = Date()
let windowStart = now.addingTimeInterval(-window)
// Clean up old requests
if var requests = requestCounts[provider] {
requests = requests.filter { $0 > windowStart }
requestCounts[provider] = requests
// Check if at limit
if requests.count >= limit {
// Wait until oldest request falls out of window
if let oldestInWindow = requests.sorted().first {
let waitUntil = oldestInWindow.addingTimeInterval(window)
let waitTime = waitUntil.timeIntervalSince(now)
if waitTime > 0 {
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
}
}
}
}
}
private func recordRequest(for provider: String) {
let now = Date()
lastRequestTimes[provider] = now
if requestCounts[provider] == nil {
requestCounts[provider] = []
}
requestCounts[provider]?.append(now)
// Clean up old requests periodically
if let requests = requestCounts[provider], requests.count > 1000 {
let oneHourAgo = now.addingTimeInterval(-3600)
requestCounts[provider] = requests.filter { $0 > oneHourAgo }
}
}
}