Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
68 changes: 55 additions & 13 deletions .claude/rules/fault-tolerance.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,41 @@ This rule covers retry, rate limiting, and timeout configuration for API command
All commands support configurable retry, rate limiting, and timeout via CLI flags:

```bash
# Light commands (colors, typography, download subcommands)
# Light commands (colors, typography, download colors/typography/icons/images)
exfig colors --max-retries 6 --rate-limit 15 --timeout 60

# Heavy commands (icons, images) also support fail-fast and concurrent downloads
# Heavy commands (icons, images, download all) also support fail-fast and
# concurrent downloads. `download all` shares one rate-limited client across
# its colors/typography/icons/images sub-flows.
exfig icons --max-retries 4 --rate-limit 15 --timeout 90 --fail-fast
exfig icons --concurrent-downloads 50 # Increase CDN parallelism (default: 20)
exfig download all --rate-limit 25 --concurrent-downloads 50

# Batch command with timeout (overrides all per-config timeouts)
# Batch command — `--timeout` is the resolved batch-level timeout.
# In batch mode `figma.*` rate-limiting fields (incl. `timeout`) are read ONLY from
# the first config; per-target `figma.timeout` in subsequent configs is ignored
# (warned under -v). Precedence: CLI > first config's figma.timeout > built-in default.
exfig batch ./configs/ --timeout 60 --rate-limit 20

# fetch command has its own --timeout in DownloadOptions
exfig fetch -f FILE_ID -r "Frame" -o ./out --timeout 45 --fail-fast
```

**Timeout precedence:** CLI `--timeout` > PKL `figma.timeout` > FigmaClient default (30s)
**Precedence (per knob):** CLI flag > PKL `figma.*` > built-in default. Same rule applies to:
`--timeout` / `figma.timeout`, `--rate-limit` / `figma.rateLimit`, `--max-retries` / `figma.maxRetries`,
`--concurrent-downloads` / `figma.concurrentDownloads`. Boolean flags (`--fail-fast`, `--resume`) and
batch settings (`--parallel`, `batch.parallel`/`failFast`/`resume`) follow OR semantics for booleans
and standard precedence for `parallel`.

`fetch` is config-free — it does not read `figma.*` PKL fields; only CLI flags and built-in defaults apply.

`colors` and `typography` make no CDN downloads, so `figma.concurrentDownloads` is silently ignored
(under `-v` a debug log records the skip).

`exfig batch` reads `batch:` and `figma.*` rate-limiting fields ONLY from the FIRST config in argv —
per-target `batch:` blocks in subsequent configs are ignored (logged under `-v`). The shared rate
limiter and download queue mean per-config `figma.rateLimit/maxRetries/concurrentDownloads` are
intentionally unused inside the batch run.

## Implementing Fault Tolerance in New Commands

Expand Down Expand Up @@ -64,15 +84,37 @@ let data = try await client.request(endpoint)

## Defaults

| Setting | Default | Description |
| ----------------- | ------- | ------------------------------------- |
| `maxRetries` | 4 | Number of retry attempts |
| `rateLimit` | 10 | Requests per minute |
| `timeout` | 30s | Request timeout |
| `failFast` | false | Stop on first error |
| `resume` | false | Resume from checkpoint |
| `checkpointExpiry`| 24h | Checkpoint file expiration |
| `concurrentDownloads` | 20 | Parallel CDN downloads |
| Setting | Default | PKL key | Description |
| ----------------- | ------- | -------------------------------- | ------------------------------------- |
| `maxRetries` | 4 | `figma.maxRetries` | Number of retry attempts |
| `rateLimit` | 10 | `figma.rateLimit` | Requests per minute |
| `timeout` | 30s | `figma.timeout` | Request timeout |
| `failFast` | false | `batch.failFast` (batch only) | Stop on first error |
| `resume` | false | `batch.resume` (batch only) | Resume from checkpoint |
| `checkpointExpiry`| 24h | (not configurable) | Checkpoint file expiration |
| `concurrentDownloads` | 20 | `figma.concurrentDownloads` | Parallel CDN downloads |
| `parallel` | 3 | `batch.parallel` (batch only) | Concurrent batch configs |

## PKL Config Alternative

Instead of repeating CLI flags across CI workflows, set the values in `exfig.pkl`:

```pkl
figma = new Figma.FigmaConfig {
lightFileId = "..."
rateLimit = 25 // was --rate-limit 25
maxRetries = 6 // was --max-retries 6
concurrentDownloads = 50 // was --concurrent-downloads 50
timeout = 60 // was --timeout 60
}

batch = new Batch.BatchConfig {
parallel = 8 // was exfig batch --parallel 8
failFast = true // was exfig batch --fail-fast
}
```

CLI flags still override these values per-run.

## Retry Behavior

Expand Down
24 changes: 19 additions & 5 deletions Sources/ExFigCLI/Batch/BatchConfigRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,10 +222,16 @@ struct BatchConfigRunner {
let cachePath: String?
let experimentalGranularCache: Bool
let concurrentDownloads: Int
/// CLI timeout override (in seconds). When set, overrides per-config timeout.
/// Resolved batch-level timeout (in seconds). Already merged via `BatchSettingsResolver`:
/// CLI flag > FIRST config's `figma.timeout` > built-in default. Per-config `figma.timeout`
/// values in subsequent configs are intentionally ignored — `BatchSettingsResolver` warns
/// the user under `--verbose` via `.ignoredPerTargetFigmaRateLimiting`.
let cliTimeout: Int?
/// Priority for this config's downloads (lower = higher priority, based on submission order).
let configPriority: Int
/// Optional pre-evaluated PKL module cache (avoids re-eval of configs already loaded by
/// `BatchSettingsResolver`).
let moduleCache: PKLModuleCache?
/// Test-only: injected exporter for unit testing.
private let _testExporter: (any ConfigExportPerforming)?

Expand All @@ -240,9 +246,10 @@ struct BatchConfigRunner {
force: Bool = false,
cachePath: String? = nil,
experimentalGranularCache: Bool = false,
concurrentDownloads: Int = FileDownloader.defaultMaxConcurrentDownloads,
concurrentDownloads: Int = FaultToleranceDefaults.concurrentDownloads,
cliTimeout: Int? = nil,
configPriority: Int = 0,
moduleCache: PKLModuleCache? = nil,
exporter: (any ConfigExportPerforming)? = nil
) {
self.rateLimiter = rateLimiter
Expand All @@ -258,6 +265,7 @@ struct BatchConfigRunner {
self.concurrentDownloads = concurrentDownloads
self.cliTimeout = cliTimeout
self.configPriority = configPriority
self.moduleCache = moduleCache
_testExporter = exporter
}

Expand Down Expand Up @@ -299,13 +307,19 @@ struct BatchConfigRunner {
do {
var options = ExFigOptions()
options.input = configFile.url.path
try options.validate()
if let cached = await moduleCache?.get(for: configFile.url) {
try options.validateUsing(preloadedModule: cached)
} else {
try options.validate()
}

let retryHandler = RetryLogger.createHandler(ui: ui, maxAttempts: maxRetries)

// CLI timeout takes precedence over per-config timeout
// Use ONLY the batch-level resolved timeout. Per-config `figma.timeout` is
// intentionally ignored — `BatchSettingsResolver` already merged CLI flag +
// FIRST config's value, and warns about ignored per-target values under -v.
// Honoring per-config timeout here would silently override that resolution.
let effectiveTimeout: TimeInterval? = cliTimeout.map { TimeInterval($0) }
?? options.params.figma?.timeout

let baseClient = try FigmaClient(
accessToken: options.requireFigmaToken(),
Expand Down
200 changes: 200 additions & 0 deletions Sources/ExFigCLI/Batch/BatchSettingsResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import ExFigConfig
import Foundation

/// Resolved batch settings after merging CLI flags with the first config's `batch:` block
/// and `figma:` rate-limiting fields.
///
/// Precedence (per knob):
/// - CLI flag (`--parallel`, `--rate-limit`, etc.) > config value > built-in default.
/// - For `failFast`/`resume` (presence flags), CLI || config (either source enables it).
///
/// Construction is restricted to ``BatchSettingsResolver/resolve(...)`` — the resolver guarantees
/// that values fall within the documented ranges (CLI is validated, PKL values are clamped with
/// a warning when out of range).
struct ResolvedBatchSettings {
let parallel: Int
let failFast: Bool
let resume: Bool
let rateLimit: Int
let maxRetries: Int
let concurrentDownloads: Int
let timeout: Int?

fileprivate init(
parallel: Int,
failFast: Bool,
resume: Bool,
rateLimit: Int,
maxRetries: Int,
concurrentDownloads: Int,
timeout: Int?
) {
self.parallel = parallel
self.failFast = failFast
self.resume = resume
self.rateLimit = rateLimit
self.maxRetries = maxRetries
self.concurrentDownloads = concurrentDownloads
self.timeout = timeout
}
}

/// Loads the FIRST config in `exfig batch` argv and merges its `batch:` and `figma:` rate-limiting
/// fields with CLI flags. Per-target `batch:` blocks (and per-target `figma:*` rate-limiting fields)
/// in subsequent configs are ignored — under `--verbose` they emit a warning.
enum BatchSettingsResolver {
// swiftlint:disable function_parameter_count

/// - Parameters:
/// - cliParallel: `--parallel` value, or nil if user didn't pass.
/// - cliFailFast: `--fail-fast` flag (false = not set).
/// - cliResume: `--resume` flag (false = not set).
/// - cliRateLimit: `--rate-limit` value, or nil if user didn't pass.
/// - cliMaxRetries: `--max-retries` value, or nil if user didn't pass.
/// - cliConcurrentDownloads: `--concurrent-downloads` value, or nil if user didn't pass.
/// - cliTimeout: `--timeout` value (seconds), or nil if user didn't pass.
/// - allConfigs: Discovered config URLs in argv order. First wins for batch settings.
/// - verbose: When true, emit a warning for ignored per-target `batch:` blocks.
/// - ui: Terminal UI for debug/warn output.
/// - moduleCache: Optional cache to populate with the first-config evaluation result so
/// downstream consumers (`BatchConfigRunner`) can skip a redundant PKL eval.
/// - Returns: Resolved settings to drive the batch run.
static func resolve(
cliParallel: Int?,
cliFailFast: Bool,
cliResume: Bool,
cliRateLimit: Int?,
cliMaxRetries: Int?,
cliConcurrentDownloads: Int?,
cliTimeout: Int?,
allConfigs: [URL],
verbose: Bool,
ui: TerminalUI,
moduleCache: PKLModuleCache? = nil
) async -> ResolvedBatchSettings {
let firstConfig: ExFig.ModuleImpl? = await loadFirstConfig(
allConfigs: allConfigs,
ui: ui,
moduleCache: moduleCache
)
let batch = firstConfig?.batch
let figma = firstConfig?.figma

if verbose, allConfigs.count > 1 {
await logIgnoredPerTargetSettings(
otherConfigs: Array(allConfigs.dropFirst()),
ui: ui,
moduleCache: moduleCache
)
}

return ResolvedBatchSettings(
parallel: cliParallel
?? FaultToleranceValidator.sanitizedParallel(batch?.parallel, ui: ui),
failFast: cliFailFast || (batch?.failFast ?? false),
resume: cliResume || (batch?.resume ?? false),
rateLimit: cliRateLimit
?? FaultToleranceValidator.sanitizedRateLimit(figma?.rateLimit, ui: ui),
maxRetries: cliMaxRetries
?? FaultToleranceValidator.sanitizedMaxRetries(figma?.maxRetries, ui: ui),
concurrentDownloads: cliConcurrentDownloads
?? FaultToleranceValidator.sanitizedConcurrentDownloads(figma?.concurrentDownloads, ui: ui),
timeout: cliTimeout
?? FaultToleranceValidator.sanitizedTimeout(figma?.timeout.map { Int($0) }, ui: ui)
)
}

// swiftlint:enable function_parameter_count

// MARK: - Internals

private static func loadFirstConfig(
allConfigs: [URL],
ui: TerminalUI,
moduleCache: PKLModuleCache?
) async -> ExFig.ModuleImpl? {
guard let firstURL = allConfigs.first else { return nil }
do {
let module = try await PKLEvaluator.evaluate(configPath: firstURL)
await moduleCache?.set(module, for: firstURL)
return module
} catch {
// File-not-found will surface again in BatchConfigRunner with a clearer message;
// for that case we keep the message under -v. For real PKL/syntax/network errors,
// batch settings from the user are silently dropped — promote to a visible warning
// so the user knows defaults are in effect.
if isFileNotFound(error: error, url: firstURL) {
ui.debug(
"Pre-load skipped: \(firstURL.lastPathComponent) not found. " +
"BatchConfigRunner will surface the error per-config."
)
} else {
ui.warning(.batchSettingsPreloadFailed(
file: firstURL.lastPathComponent,
error: error.localizedDescription
))
}
return nil
}
}

private static func logIgnoredPerTargetSettings(
otherConfigs: [URL],
ui: TerminalUI,
moduleCache: PKLModuleCache?
) async {
for url in otherConfigs {
let module: ExFig.ModuleImpl?
do {
module = try await PKLEvaluator.evaluate(configPath: url)
await moduleCache?.set(module, for: url)
} catch {
if isFileNotFound(error: error, url: url) {
// BatchConfigRunner will surface a clearer per-config error; debug only.
ui.debug(
"Pre-check skipped: \(url.lastPathComponent) not found."
)
} else {
// Real PKL/syntax/network errors mean we cannot tell whether the user
// had per-target batch:/figma:* fields. Surface as warning so the user
// doesn't think their config was silently accepted.
ui.warning(.batchSettingsPreloadFailed(
file: url.lastPathComponent,
error: error.localizedDescription
))
}
continue
}
if module?.batch != nil {
ui.warning(.ignoredPerTargetBatchBlock(file: url.lastPathComponent))
}
if let figma = module?.figma,
figma.rateLimit != nil
|| figma.maxRetries != nil
|| figma.concurrentDownloads != nil
|| figma.timeout != nil
{
ui.warning(.ignoredPerTargetFigmaRateLimiting(file: url.lastPathComponent))
}
}
}

/// Detect "file not found" errors structurally. We check the filesystem FIRST so the
/// classification doesn't depend on Foundation/PklSwift error message wording.
/// Falls back to NSError domain/code checks for completeness; deliberately does NOT
/// match arbitrary "not found" substrings (a real PKL error like
/// `module member 'foo' not found` should NOT be reclassified as missing-file).
private static func isFileNotFound(error: Error, url: URL) -> Bool {
if !FileManager.default.fileExists(atPath: url.path) {
return true
}
let nsError = error as NSError
if nsError.domain == NSCocoaErrorDomain, nsError.code == NSFileReadNoSuchFileError {
return true
}
if nsError.domain == NSPOSIXErrorDomain, nsError.code == Int(ENOENT) {
return true
}
return false
}
}
27 changes: 27 additions & 0 deletions Sources/ExFigCLI/Batch/PKLModuleCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import ExFigConfig
import Foundation

/// Caches PKL evaluation results by config URL so the first config (and any subsequent ones
/// pre-checked under `--verbose`) doesn't pay the eval cost twice.
///
/// PKL evaluation spawns a subprocess and is expensive — re-using the parsed module across
/// `BatchSettingsResolver`, `logIgnoredPerTargetSettings`, and `BatchConfigRunner` saves a
/// noticeable chunk of pre-batch latency.
actor PKLModuleCache {
private var modules: [URL: ExFig.ModuleImpl] = [:]

init() {}

func set(_ module: ExFig.ModuleImpl?, for url: URL) {
guard let module else { return }
modules[standardize(url)] = module
}

func get(for url: URL) -> ExFig.ModuleImpl? {
modules[standardize(url)]
}

private func standardize(_ url: URL) -> URL {
url.standardizedFileURL
}
}
Loading
Loading