Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -84,6 +87,10 @@ let package = Package(
.testTarget(
name: "AndroidExportTests",
dependencies: ["AndroidExport", .product(name: "CustomDump", package: "swift-custom-dump")]
),
.testTarget(
name: "FigmaAPITests",
dependencies: ["FigmaAPI"]
)
]
)
112 changes: 101 additions & 11 deletions Sources/FigmaAPI/Client.swift
Original file line number Diff line number Diff line change
@@ -1,62 +1,135 @@
import Foundation
import Logging
#if os(Linux)
import FoundationNetworking
#endif

public typealias APIResult<Value> = Swift.Result<Value, Error>

public protocol Client {

func request<T>(_ endpoint: T) throws -> T.Content where T: Endpoint

func request<T>(
_ endpoint: T,
completion: @escaping (APIResult<T.Content>) -> Void ) -> URLSessionTask where T: Endpoint

func requestWithRetry<T>(
_ 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<T>(_ endpoint: T) throws -> T.Content where T: Endpoint {
var outResult: APIResult<T.Content>!

let task = request(endpoint, completion: { result in
outResult = result
})
task.wait()

return try outResult.get()
}

public func request<T>(
_ endpoint: T,
completion: @escaping (APIResult<T.Content>) -> 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<T.Content>(catching: { () -> T.Content in
return try endpoint.content(from: response, with: data)
})

completion(content)
}
task.resume()
return task
}

public func requestWithRetry<T>(
_ endpoint: T,
configuration: RetryConfiguration = .default
) throws -> T.Content where T: Endpoint {

var lastError: Error?
var currentBackoff = configuration.initialBackoffSeconds

for attempt in 0..<configuration.maxRetries {
do {
let result = try request(endpoint)
// Delay after successful request to prevent hitting rate limits on subsequent calls
if configuration.requestDelaySeconds > 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 {
Expand All @@ -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
}
32 changes: 29 additions & 3 deletions Sources/FigmaAPI/FigmaClient.swift
Original file line number Diff line number Diff line change
@@ -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<T>(_ endpoint: T) throws -> T.Content where T: Endpoint {
return try requestWithRetry(endpoint, configuration: retryConfiguration)
}

}
34 changes: 34 additions & 0 deletions Sources/FigmaAPI/HTTPURLResponse+RateLimit.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
23 changes: 23 additions & 0 deletions Sources/FigmaAPI/Model/RateLimitError.swift
Original file line number Diff line number Diff line change
@@ -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..."
}
}
}
28 changes: 28 additions & 0 deletions Sources/FigmaAPI/RetryConfiguration.swift
Original file line number Diff line number Diff line change
@@ -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()
}
1 change: 1 addition & 0 deletions Sources/FigmaExport/Input/Params.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions Sources/FigmaExport/Loaders/Colors/ColorsLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, VariableCollectionValue>.Values.Element) -> ModeIds {
Expand Down
4 changes: 2 additions & 2 deletions Sources/FigmaExport/Loaders/ImagesLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions Sources/FigmaExport/Loaders/TextStylesLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading