diff --git a/Package.swift b/Package.swift index 18774b21..2e53d1ef 100644 --- a/Package.swift +++ b/Package.swift @@ -41,7 +41,10 @@ let package = Package( .target(name: "FigmaExportCore"), // Loads data via Figma REST API - .target(name: "FigmaAPI"), + .target( + name: "FigmaAPI", + dependencies: [.product(name: "Logging", package: "swift-log")] + ), // Exports resources to Xcode project .target( @@ -84,6 +87,10 @@ let package = Package( .testTarget( name: "AndroidExportTests", dependencies: ["AndroidExport", .product(name: "CustomDump", package: "swift-custom-dump")] + ), + .testTarget( + name: "FigmaAPITests", + dependencies: ["FigmaAPI"] ) ] ) diff --git a/Sources/FigmaAPI/Client.swift b/Sources/FigmaAPI/Client.swift index fb18c358..f78dae89 100644 --- a/Sources/FigmaAPI/Client.swift +++ b/Sources/FigmaAPI/Client.swift @@ -1,4 +1,5 @@ import Foundation +import Logging #if os(Linux) import FoundationNetworking #endif @@ -6,28 +7,33 @@ import FoundationNetworking public typealias APIResult = Swift.Result public protocol Client { - + func request(_ endpoint: T) throws -> T.Content where T: Endpoint - + func request( _ endpoint: T, completion: @escaping (APIResult) -> Void ) -> URLSessionTask where T: Endpoint + + func requestWithRetry( + _ endpoint: T, + configuration: RetryConfiguration + ) throws -> T.Content where T: Endpoint } public class BaseClient: Client { - + private let baseURL: URL - private let session: URLSession - + private let logger = Logger(label: "FigmaAPI.Client") + public init(baseURL: URL, config: URLSessionConfiguration) { self.baseURL = baseURL session = URLSession(configuration: config, delegate: nil, delegateQueue: .main) } - + public func request(_ endpoint: T) throws -> T.Content where T: Endpoint { var outResult: APIResult! - + let task = request(endpoint, completion: { result in outResult = result }) @@ -35,28 +41,95 @@ public class BaseClient: Client { return try outResult.get() } - + public func request( _ endpoint: T, completion: @escaping (APIResult) -> Void ) -> URLSessionTask where T: Endpoint { - + let request = endpoint.makeRequest(baseURL: baseURL) let task = session.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) in - + + // Handle network errors including timeout + if let urlError = error as? URLError, urlError.code == .timedOut { + completion(.failure(RateLimitError.timeout)) + return + } + guard let data = data, error == nil else { completion(.failure(error!)) return } + + // Check for HTTP 429 rate limiting + if let httpResponse = response as? HTTPURLResponse, httpResponse.isRateLimited { + let retryAfter = httpResponse.extractRetryAfter() + completion(.failure(RateLimitError.rateLimited(retryAfter: retryAfter))) + return + } + let content = APIResult(catching: { () -> T.Content in return try endpoint.content(from: response, with: data) }) - + completion(content) } task.resume() return task } + public func requestWithRetry( + _ endpoint: T, + configuration: RetryConfiguration = .default + ) throws -> T.Content where T: Endpoint { + + var lastError: Error? + var currentBackoff = configuration.initialBackoffSeconds + + for attempt in 0.. 0 { + logger.info("Throttling: waiting \(String(format: "%.1f", configuration.requestDelaySeconds))s before next request") + sleep(forTimeInterval: configuration.requestDelaySeconds) + } + return result + } catch let error as RateLimitError { + switch error { + case .rateLimited(let retryAfter): + // Exit immediately if retry-after exceeds max allowed wait time + if retryAfter > configuration.maxRetryAfterSeconds { + throw RateLimitError.rateLimitExceeded(retryAfter: retryAfter) + } + + logger.warning("Rate limited by Figma API. Waiting \(Int(retryAfter))s before retry \(attempt + 1)/\(configuration.maxRetries)") + sleep(forTimeInterval: retryAfter) + lastError = error + + case .timeout: + // Exponential backoff for timeout + logger.warning("Request timed out. Waiting \(Int(currentBackoff))s before retry \(attempt + 1)/\(configuration.maxRetries)") + sleep(forTimeInterval: currentBackoff) + currentBackoff *= configuration.backoffMultiplier + lastError = error + + case .rateLimitExceeded: + // Do not retry, exit immediately + throw error + } + } catch { + // Other errors: do not retry + throw error + } + } + + // All retries exhausted + if let lastError = lastError { + throw lastError + } + throw RateLimitError.timeout + } + } private extension URLSessionTask { @@ -71,3 +144,20 @@ private extension URLSessionTask { } } + +/// Sleeps for the specified interval while keeping the RunLoop responsive. +/// Uses RunLoop when possible to process pending callbacks, with Thread.sleep +/// fallback when RunLoop has no active sources (prevents immediate return). +/// On Linux, uses Thread.sleep directly as RunLoop behavior differs. +private func sleep(forTimeInterval interval: TimeInterval) { + #if os(Linux) + Thread.sleep(forTimeInterval: interval) + #else + let limitDate = Date(timeIntervalSinceNow: interval) + while Date() < limitDate { + if !RunLoop.current.run(mode: .default, before: limitDate) { + Thread.sleep(forTimeInterval: 0.1) + } + } + #endif +} diff --git a/Sources/FigmaAPI/FigmaClient.swift b/Sources/FigmaAPI/FigmaClient.swift index 29828f3a..d14af4c6 100644 --- a/Sources/FigmaAPI/FigmaClient.swift +++ b/Sources/FigmaAPI/FigmaClient.swift @@ -1,17 +1,43 @@ +// ABOUTME: FigmaClient is the main API client for Figma REST API +// ABOUTME: Supports automatic retry with exponential backoff for rate limiting + import Foundation #if os(Linux) import FoundationNetworking #endif final public class FigmaClient: BaseClient { - + private let baseURL = URL(string: "https://api.figma.com/v1/")! - - public init(accessToken: String, timeout: TimeInterval?) { + private let retryConfiguration: RetryConfiguration + + public init( + accessToken: String, + timeout: TimeInterval?, + retryConfiguration: RetryConfiguration = .default, + requestDelay: TimeInterval? = nil + ) { + // If requestDelay is explicitly provided, override the configuration value + if let requestDelay = requestDelay { + self.retryConfiguration = RetryConfiguration( + maxRetries: retryConfiguration.maxRetries, + maxRetryAfterSeconds: retryConfiguration.maxRetryAfterSeconds, + initialBackoffSeconds: retryConfiguration.initialBackoffSeconds, + backoffMultiplier: retryConfiguration.backoffMultiplier, + requestDelaySeconds: requestDelay + ) + } else { + self.retryConfiguration = retryConfiguration + } let config = URLSessionConfiguration.ephemeral config.httpAdditionalHeaders = ["X-Figma-Token": accessToken] config.timeoutIntervalForRequest = timeout ?? 30 super.init(baseURL: baseURL, config: config) } + /// Convenience method that uses the client's default retry configuration + public func requestWithRetry(_ endpoint: T) throws -> T.Content where T: Endpoint { + return try requestWithRetry(endpoint, configuration: retryConfiguration) + } + } diff --git a/Sources/FigmaAPI/HTTPURLResponse+RateLimit.swift b/Sources/FigmaAPI/HTTPURLResponse+RateLimit.swift new file mode 100644 index 00000000..2f2d821e --- /dev/null +++ b/Sources/FigmaAPI/HTTPURLResponse+RateLimit.swift @@ -0,0 +1,34 @@ +// ABOUTME: Extension to HTTPURLResponse for rate limit detection +// ABOUTME: Extracts Retry-After header and checks for HTTP 429 status + +import Foundation +#if os(Linux) +import FoundationNetworking +#endif + +public extension HTTPURLResponse { + + /// Returns true if the response indicates rate limiting (HTTP 429) + var isRateLimited: Bool { + statusCode == 429 + } + + /// Extracts the Retry-After value from response headers + /// - Returns: The number of seconds to wait before retrying, defaults to 60 if not present or invalid + func extractRetryAfter() -> TimeInterval { + // Retry-After can be in seconds (e.g., "120") or HTTP date format + // We only support the seconds format for simplicity + // Use allHeaderFields for compatibility with macOS 10.13+ + let headers = allHeaderFields + if let retryAfterString = headers["Retry-After"] as? String, + let seconds = TimeInterval(retryAfterString) { + return seconds + } + // Also check lowercase variant (HTTP headers are case-insensitive) + if let retryAfterString = headers["retry-after"] as? String, + let seconds = TimeInterval(retryAfterString) { + return seconds + } + return 60 // Default fallback + } +} diff --git a/Sources/FigmaAPI/Model/RateLimitError.swift b/Sources/FigmaAPI/Model/RateLimitError.swift new file mode 100644 index 00000000..bdf2b25f --- /dev/null +++ b/Sources/FigmaAPI/Model/RateLimitError.swift @@ -0,0 +1,23 @@ +// ABOUTME: Errors related to Figma API rate limiting +// ABOUTME: Handles HTTP 429, timeouts, and retry-after exceeded scenarios + +import Foundation + +public enum RateLimitError: LocalizedError, Equatable { + case rateLimited(retryAfter: TimeInterval) + case rateLimitExceeded(retryAfter: TimeInterval) // > max allowed wait time + case timeout + + public var errorDescription: String? { + switch self { + case .rateLimited(let retryAfter): + return "Rate limited by Figma API. Retrying in \(Int(retryAfter)) seconds..." + case .rateLimitExceeded(let retryAfter): + let minutes = Int(retryAfter / 60) + return "Figma API rate limit exceeded. Retry-After: \(minutes) minutes. " + + "Please wait and try again later, or check your API usage." + case .timeout: + return "Request to Figma API timed out. Retrying..." + } + } +} diff --git a/Sources/FigmaAPI/RetryConfiguration.swift b/Sources/FigmaAPI/RetryConfiguration.swift new file mode 100644 index 00000000..23e6e6d0 --- /dev/null +++ b/Sources/FigmaAPI/RetryConfiguration.swift @@ -0,0 +1,28 @@ +// ABOUTME: Configuration for retry behavior on API failures +// ABOUTME: Defines max retries, backoff strategy, and timeout thresholds + +import Foundation + +public struct RetryConfiguration { + public let maxRetries: Int + public let maxRetryAfterSeconds: TimeInterval // Exit if Retry-After exceeds this + public let initialBackoffSeconds: TimeInterval + public let backoffMultiplier: Double + public let requestDelaySeconds: TimeInterval // Delay after each successful request to prevent rate limiting + + public init( + maxRetries: Int = 3, + maxRetryAfterSeconds: TimeInterval = 300, // 5 minutes + initialBackoffSeconds: TimeInterval = 1.0, + backoffMultiplier: Double = 2.0, + requestDelaySeconds: TimeInterval = 0 + ) { + self.maxRetries = maxRetries + self.maxRetryAfterSeconds = maxRetryAfterSeconds + self.initialBackoffSeconds = initialBackoffSeconds + self.backoffMultiplier = backoffMultiplier + self.requestDelaySeconds = requestDelaySeconds + } + + public static let `default` = RetryConfiguration() +} diff --git a/Sources/FigmaExport/Input/Params.swift b/Sources/FigmaExport/Input/Params.swift index d29f6c2c..451180fd 100644 --- a/Sources/FigmaExport/Input/Params.swift +++ b/Sources/FigmaExport/Input/Params.swift @@ -9,6 +9,7 @@ struct Params: Decodable { let lightHighContrastFileId: String? let darkHighContrastFileId: String? let timeout: TimeInterval? + let requestDelay: TimeInterval? // Delay between API requests to prevent rate limiting } struct Common: Decodable { diff --git a/Sources/FigmaExport/Loaders/Colors/ColorsLoader.swift b/Sources/FigmaExport/Loaders/Colors/ColorsLoader.swift index 153fcd2c..3a26558b 100644 --- a/Sources/FigmaExport/Loaders/Colors/ColorsLoader.swift +++ b/Sources/FigmaExport/Loaders/Colors/ColorsLoader.swift @@ -101,7 +101,7 @@ final class ColorsLoader { private func loadStyles(fileId: String) throws -> [Style] { let endpoint = StylesEndpoint(fileId: fileId) - let styles = try client.request(endpoint) + let styles = try client.requestWithRetry(endpoint, configuration: .default) return styles.filter { $0.styleType == .fill && useStyle($0) } @@ -116,6 +116,6 @@ final class ColorsLoader { private func loadNodes(fileId: String, nodeIds: [String]) throws -> [NodeId: Node] { let endpoint = NodesEndpoint(fileId: fileId, nodeIds: nodeIds) - return try client.request(endpoint) + return try client.requestWithRetry(endpoint, configuration: .default) } } diff --git a/Sources/FigmaExport/Loaders/Colors/ColorsVariablesLoader.swift b/Sources/FigmaExport/Loaders/Colors/ColorsVariablesLoader.swift index 7c5212d6..2b2e4c63 100644 --- a/Sources/FigmaExport/Loaders/Colors/ColorsVariablesLoader.swift +++ b/Sources/FigmaExport/Loaders/Colors/ColorsVariablesLoader.swift @@ -39,7 +39,7 @@ final class ColorsVariablesLoader { private func loadVariables(fileId: String) throws -> VariablesEndpoint.Content { let endpoint = VariablesEndpoint(fileId: fileId) - return try client.request(endpoint) + return try client.requestWithRetry(endpoint, configuration: .default) } private func extractModeIds(from collections: Dictionary.Values.Element) -> ModeIds { diff --git a/Sources/FigmaExport/Loaders/ImagesLoader.swift b/Sources/FigmaExport/Loaders/ImagesLoader.swift index 4b6d963f..2e18b4e6 100644 --- a/Sources/FigmaExport/Loaders/ImagesLoader.swift +++ b/Sources/FigmaExport/Loaders/ImagesLoader.swift @@ -325,7 +325,7 @@ final class ImagesLoader { private func loadComponents(fileId: String) throws -> [Component] { let endpoint = ComponentsEndpoint(fileId: fileId) - return try client.request(endpoint) + return try client.requestWithRetry(endpoint, configuration: .default) } private func loadImages(fileId: String, imagesDict: [NodeId: Component], params: FormatParams) throws -> [NodeId: ImagePath] { @@ -335,7 +335,7 @@ final class ImagesLoader { let keysWithValues: [(NodeId, ImagePath)] = try nodeIds.chunked(into: batchSize) .map { ImageEndpoint(fileId: fileId, nodeIds: $0, params: params) } - .map { try client.request($0) } + .map { try client.requestWithRetry($0, configuration: .default) } .flatMap { dict in dict.compactMap { nodeId, imagePath in if let imagePath { diff --git a/Sources/FigmaExport/Loaders/TextStylesLoader.swift b/Sources/FigmaExport/Loaders/TextStylesLoader.swift index 35b75ea2..ac7f2f7b 100644 --- a/Sources/FigmaExport/Loaders/TextStylesLoader.swift +++ b/Sources/FigmaExport/Loaders/TextStylesLoader.swift @@ -55,12 +55,12 @@ final class TextStylesLoader { private func loadStyles(fileId: String) throws -> [Style] { let endpoint = StylesEndpoint(fileId: fileId) - let styles = try client.request(endpoint) + let styles = try client.requestWithRetry(endpoint, configuration: .default) return styles.filter { $0.styleType == .text } } private func loadNodes(fileId: String, nodeIds: [String]) throws -> [NodeId: Node] { let endpoint = NodesEndpoint(fileId: fileId, nodeIds: nodeIds) - return try client.request(endpoint) + return try client.requestWithRetry(endpoint, configuration: .default) } } diff --git a/Sources/FigmaExport/Subcommands/ExportColors.swift b/Sources/FigmaExport/Subcommands/ExportColors.swift index 5277e5bb..3ceee052 100644 --- a/Sources/FigmaExport/Subcommands/ExportColors.swift +++ b/Sources/FigmaExport/Subcommands/ExportColors.swift @@ -28,7 +28,7 @@ extension FigmaExportCommand { logger.info("Using FigmaExport \(FigmaExportCommand.version) to export colors.") logger.info("Fetching colors. Please wait...") - let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma.timeout) + let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma.timeout, requestDelay: options.params.figma.requestDelay) let commonParams = options.params.common if commonParams?.colors != nil, commonParams?.variablesColors != nil { diff --git a/Sources/FigmaExport/Subcommands/ExportIcons.swift b/Sources/FigmaExport/Subcommands/ExportIcons.swift index a98b064d..dd66b4bd 100644 --- a/Sources/FigmaExport/Subcommands/ExportIcons.swift +++ b/Sources/FigmaExport/Subcommands/ExportIcons.swift @@ -28,7 +28,7 @@ extension FigmaExportCommand { var filter: String? func run() throws { - let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma.timeout) + let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma.timeout, requestDelay: options.params.figma.requestDelay) if options.params.ios != nil { logger.info("Using FigmaExport \(FigmaExportCommand.version) to export icons to Xcode project.") diff --git a/Sources/FigmaExport/Subcommands/ExportImages.swift b/Sources/FigmaExport/Subcommands/ExportImages.swift index 8dc5ded0..f0128eb0 100644 --- a/Sources/FigmaExport/Subcommands/ExportImages.swift +++ b/Sources/FigmaExport/Subcommands/ExportImages.swift @@ -25,7 +25,7 @@ extension FigmaExportCommand { var filter: String? func run() throws { - let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma.timeout) + let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma.timeout, requestDelay: options.params.figma.requestDelay) if let _ = options.params.ios { logger.info("Using FigmaExport \(FigmaExportCommand.version) to export images to Xcode project.") diff --git a/Sources/FigmaExport/Subcommands/ExportTypography.swift b/Sources/FigmaExport/Subcommands/ExportTypography.swift index 3a357135..f9f6eed2 100644 --- a/Sources/FigmaExport/Subcommands/ExportTypography.swift +++ b/Sources/FigmaExport/Subcommands/ExportTypography.swift @@ -18,7 +18,7 @@ extension FigmaExportCommand { var options: FigmaExportOptions func run() throws { - let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma.timeout) + let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma.timeout, requestDelay: options.params.figma.requestDelay) logger.info("Using FigmaExport \(FigmaExportCommand.version) to export typography.") diff --git a/Tests/FigmaAPITests/RateLimitingTests.swift b/Tests/FigmaAPITests/RateLimitingTests.swift new file mode 100644 index 00000000..a1c7956b --- /dev/null +++ b/Tests/FigmaAPITests/RateLimitingTests.swift @@ -0,0 +1,158 @@ +// ABOUTME: Tests for rate limiting error types and retry configuration +// ABOUTME: Validates error messages, retry-after parsing, and configuration defaults + +import XCTest +import Foundation +#if os(Linux) +import FoundationNetworking +#endif +@testable import FigmaAPI + +final class RateLimitingTests: XCTestCase { + + // MARK: - RateLimitError Tests + + func testRateLimitedErrorDescription() { + let error = RateLimitError.rateLimited(retryAfter: 60) + XCTAssertEqual( + error.errorDescription, + "Rate limited by Figma API. Retrying in 60 seconds..." + ) + } + + func testRateLimitExceededErrorDescription() { + let error = RateLimitError.rateLimitExceeded(retryAfter: 600) + XCTAssertEqual( + error.errorDescription, + "Figma API rate limit exceeded. Retry-After: 10 minutes. " + + "Please wait and try again later, or check your API usage." + ) + } + + func testTimeoutErrorDescription() { + let error = RateLimitError.timeout + XCTAssertEqual( + error.errorDescription, + "Request to Figma API timed out. Retrying..." + ) + } + + func testRateLimitErrorEquality() { + XCTAssertEqual( + RateLimitError.rateLimited(retryAfter: 60), + RateLimitError.rateLimited(retryAfter: 60) + ) + XCTAssertNotEqual( + RateLimitError.rateLimited(retryAfter: 60), + RateLimitError.rateLimited(retryAfter: 120) + ) + XCTAssertEqual(RateLimitError.timeout, RateLimitError.timeout) + } + + // MARK: - RetryConfiguration Tests + + func testDefaultConfiguration() { + let config = RetryConfiguration.default + + XCTAssertEqual(config.maxRetries, 3) + XCTAssertEqual(config.maxRetryAfterSeconds, 300) // 5 minutes + XCTAssertEqual(config.initialBackoffSeconds, 1.0) + XCTAssertEqual(config.backoffMultiplier, 2.0) + } + + func testCustomConfiguration() { + let config = RetryConfiguration( + maxRetries: 5, + maxRetryAfterSeconds: 600, + initialBackoffSeconds: 2.0, + backoffMultiplier: 3.0, + requestDelaySeconds: 0.5 + ) + + XCTAssertEqual(config.maxRetries, 5) + XCTAssertEqual(config.maxRetryAfterSeconds, 600) + XCTAssertEqual(config.initialBackoffSeconds, 2.0) + XCTAssertEqual(config.backoffMultiplier, 3.0) + XCTAssertEqual(config.requestDelaySeconds, 0.5) + } + + func testDefaultConfigurationHasZeroRequestDelay() { + let config = RetryConfiguration.default + XCTAssertEqual(config.requestDelaySeconds, 0) + } + + // MARK: - Retry-After Header Parsing Tests + + func testExtractRetryAfterFromNumericHeader() { + let headerFields = ["Retry-After": "120"] + let response = HTTPURLResponse( + url: URL(string: "https://api.figma.com")!, + statusCode: 429, + httpVersion: nil, + headerFields: headerFields + )! + + let retryAfter = response.extractRetryAfter() + XCTAssertEqual(retryAfter, 120) + } + + func testExtractRetryAfterDefaultsTo60WhenMissing() { + let response = HTTPURLResponse( + url: URL(string: "https://api.figma.com")!, + statusCode: 429, + httpVersion: nil, + headerFields: [:] + )! + + let retryAfter = response.extractRetryAfter() + XCTAssertEqual(retryAfter, 60) // Default fallback + } + + func testExtractRetryAfterDefaultsTo60WhenInvalid() { + let headerFields = ["Retry-After": "not-a-number"] + let response = HTTPURLResponse( + url: URL(string: "https://api.figma.com")!, + statusCode: 429, + httpVersion: nil, + headerFields: headerFields + )! + + let retryAfter = response.extractRetryAfter() + XCTAssertEqual(retryAfter, 60) // Default fallback + } + + // MARK: - Rate Limit Detection Tests + + func testIsRateLimitedForStatus429() { + let response = HTTPURLResponse( + url: URL(string: "https://api.figma.com")!, + statusCode: 429, + httpVersion: nil, + headerFields: [:] + )! + + XCTAssertTrue(response.isRateLimited) + } + + func testIsNotRateLimitedForStatus200() { + let response = HTTPURLResponse( + url: URL(string: "https://api.figma.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: [:] + )! + + XCTAssertFalse(response.isRateLimited) + } + + func testIsNotRateLimitedForStatus500() { + let response = HTTPURLResponse( + url: URL(string: "https://api.figma.com")!, + statusCode: 500, + httpVersion: nil, + headerFields: [:] + )! + + XCTAssertFalse(response.isRateLimited) + } +}