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>
209 lines
6.9 KiB
Swift
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 }
|
|
}
|
|
}
|
|
}
|