Skip to content

Commit 5508425

Browse files
committed
Add retry policy, CI workflow, and changelog
1 parent d18f375 commit 5508425

File tree

8 files changed

+327
-32
lines changed

8 files changed

+327
-32
lines changed

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: ["master"]
6+
tags: ["v*"]
7+
pull_request:
8+
9+
jobs:
10+
build-and-test:
11+
name: Build and Test (${{ matrix.os }})
12+
runs-on: ${{ matrix.os }}
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
os: [ubuntu-latest, macos-14]
17+
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v4
21+
22+
- name: Setup Swift
23+
uses: swift-actions/setup-swift@v2
24+
with:
25+
swift-version: "5.9"
26+
27+
- name: Swift Version
28+
run: swift --version
29+
30+
- name: Build
31+
run: swift build --build-tests
32+
33+
- name: Test
34+
run: swift test

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
6+
7+
## [Unreleased]
8+
9+
### Added
10+
- Configurable retry policy in `ConvertOptions` (`maxRetryCount`, `retryBaseDelay`).
11+
- Exponential backoff retry handling for Workers AI calls on transient network errors and HTTP `429/5xx`.
12+
- CI workflow (`.github/workflows/ci.yml`) to build and test on macOS and Linux.
13+
14+
### Changed
15+
- `MarkdownConverter` now passes retry configuration to `CloudflareClient`.
16+
- README now includes CI badge and retry behavior notes.
17+
18+
## [0.1.0] - 2026-02-24
19+
20+
### Added
21+
- Initial Swift2MD library and CLI implementation.
22+
- Workers AI `toMarkdown()` client with multipart upload support.
23+
- Strongly typed format detection and API response models.
24+
- Unit test suite (format parsing, multipart, client, converter, integration test gate).
25+
- README documentation and MIT license.

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Swift2MD
22

33
[![](https://img.shields.io/github/v/tag/herrkaefer/Swift2MD?label=version)](https://github.com/herrkaefer/Swift2MD/tags)
4+
[![CI](https://github.com/herrkaefer/Swift2MD/actions/workflows/ci.yml/badge.svg)](https://github.com/herrkaefer/Swift2MD/actions/workflows/ci.yml)
45
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fherrkaefer%2FSwift2MD%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/herrkaefer/Swift2MD)
56
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fherrkaefer%2FSwift2MD%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/herrkaefer/Swift2MD)
67
[![](https://img.shields.io/badge/platforms-macOS%2013%2B%20%7C%20iOS%2016%2B-0A84FF)](https://swiftpackageindex.com/herrkaefer/Swift2MD)
@@ -21,6 +22,12 @@ Swift2MD is a lightweight Swift package and CLI for converting URLs or local doc
2122
- A Cloudflare account
2223
- A Cloudflare API token that can call Workers AI
2324

25+
## Reliability Defaults
26+
27+
- API calls retry up to `2` times on retryable failures (`429`, `5xx`, and transient network errors).
28+
- Backoff is exponential with a default base delay of `300ms`.
29+
- You can override both values with `ConvertOptions`.
30+
2431
## Supported Input Formats
2532

2633
`pdf`, `jpeg/jpg`, `png`, `webp`, `svg`, `html/htm`, `xml`, `csv`, `docx`, `xlsx`, `xlsm`, `xlsb`, `xls`, `et`, `ods`, `odt`, `numbers`
@@ -52,7 +59,14 @@ let credentials = CloudflareCredentials(
5259
apiToken: "<CLOUDFLARE_API_TOKEN>"
5360
)
5461

55-
let converter = MarkdownConverter(credentials: credentials)
62+
let converter = MarkdownConverter(
63+
credentials: credentials,
64+
options: ConvertOptions(
65+
timeout: .seconds(60),
66+
maxRetryCount: 2,
67+
retryBaseDelay: .milliseconds(300)
68+
)
69+
)
5670

5771
// Convert remote URL
5872
let urlResult = try await converter.convert(URL(string: "https://example.com/file.pdf")!)
@@ -133,6 +147,8 @@ swift test
133147
swift run swift2md --help
134148
```
135149

150+
See [CHANGELOG.md](CHANGELOG.md) for release history.
151+
136152
Integration test is opt-in with environment variables:
137153

138154
- `SWIFT2MD_RUN_INTEGRATION=1`

Sources/Swift2MD/CloudflareClient.swift

Lines changed: 91 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,21 @@ struct CloudflareClient: Sendable {
77
let credentials: CloudflareCredentials
88
let session: URLSession
99
let timeout: Duration
10-
11-
init(credentials: CloudflareCredentials, session: URLSession = .shared, timeout: Duration = .seconds(60)) {
10+
let maxRetryCount: Int
11+
let retryBaseDelay: Duration
12+
13+
init(
14+
credentials: CloudflareCredentials,
15+
session: URLSession = .shared,
16+
timeout: Duration = .seconds(60),
17+
maxRetryCount: Int = 2,
18+
retryBaseDelay: Duration = .milliseconds(300)
19+
) {
1220
self.credentials = credentials
1321
self.session = session
1422
self.timeout = timeout
23+
self.maxRetryCount = max(0, maxRetryCount)
24+
self.retryBaseDelay = retryBaseDelay
1525
}
1626

1727
func toMarkdown(files: [(data: Data, filename: String)]) async throws -> [ConversionResult] {
@@ -33,39 +43,93 @@ struct CloudflareClient: Sendable {
3343
request.setValue(multipart.contentType, forHTTPHeaderField: "Content-Type")
3444
request.httpBody = multipart.finalize()
3545

36-
let data: Data
37-
let response: URLResponse
38-
39-
do {
40-
(data, response) = try await session.data(for: request)
41-
} catch {
42-
throw Swift2MDError.networkError(underlying: error)
46+
var attempt = 0
47+
48+
while true {
49+
do {
50+
let (data, response) = try await session.data(for: request)
51+
52+
guard let httpResponse = response as? HTTPURLResponse else {
53+
throw Swift2MDError.invalidResponse
54+
}
55+
56+
let responseBody = String(data: data, encoding: .utf8) ?? ""
57+
guard (200...299).contains(httpResponse.statusCode) else {
58+
if shouldRetry(statusCode: httpResponse.statusCode, attempt: attempt) {
59+
throw RetryableRequestError()
60+
}
61+
throw Swift2MDError.httpError(statusCode: httpResponse.statusCode, body: responseBody)
62+
}
63+
64+
do {
65+
let decoded = try JSONDecoder().decode(APIEnvelope.self, from: data)
66+
guard decoded.success else {
67+
let messages = decoded.errors.map(\.message) + decoded.messages.map(\.message)
68+
throw Swift2MDError.apiError(messages: messages)
69+
}
70+
return decoded.result
71+
} catch let error as Swift2MDError {
72+
throw error
73+
} catch {
74+
throw Swift2MDError.invalidResponse
75+
}
76+
} catch is RetryableRequestError {
77+
try await sleepBeforeRetry(attempt: attempt)
78+
attempt += 1
79+
continue
80+
} catch let error as Swift2MDError {
81+
throw error
82+
} catch {
83+
if shouldRetry(networkError: error, attempt: attempt) {
84+
try await sleepBeforeRetry(attempt: attempt)
85+
attempt += 1
86+
continue
87+
}
88+
throw Swift2MDError.networkError(underlying: error)
89+
}
4390
}
91+
}
4492

45-
guard let httpResponse = response as? HTTPURLResponse else {
46-
throw Swift2MDError.invalidResponse
47-
}
93+
private func shouldRetry(statusCode: Int, attempt: Int) -> Bool {
94+
guard attempt < maxRetryCount else { return false }
95+
return statusCode == 429 || statusCode >= 500
96+
}
4897

49-
let responseBody = String(data: data, encoding: .utf8) ?? ""
50-
guard (200...299).contains(httpResponse.statusCode) else {
51-
throw Swift2MDError.httpError(statusCode: httpResponse.statusCode, body: responseBody)
98+
private func shouldRetry(networkError: Error, attempt: Int) -> Bool {
99+
guard attempt < maxRetryCount else { return false }
100+
if networkError is CancellationError { return false }
101+
102+
guard let urlError = networkError as? URLError else { return false }
103+
switch urlError.code {
104+
case .timedOut,
105+
.cannotFindHost,
106+
.cannotConnectToHost,
107+
.networkConnectionLost,
108+
.dnsLookupFailed,
109+
.notConnectedToInternet,
110+
.internationalRoamingOff,
111+
.callIsActive,
112+
.dataNotAllowed,
113+
.resourceUnavailable:
114+
return true
115+
default:
116+
return false
52117
}
118+
}
53119

54-
do {
55-
let decoded = try JSONDecoder().decode(APIEnvelope.self, from: data)
56-
guard decoded.success else {
57-
let messages = decoded.errors.map(\.message) + decoded.messages.map(\.message)
58-
throw Swift2MDError.apiError(messages: messages)
59-
}
60-
return decoded.result
61-
} catch let error as Swift2MDError {
62-
throw error
63-
} catch {
64-
throw Swift2MDError.invalidResponse
65-
}
120+
private func sleepBeforeRetry(attempt: Int) async throws {
121+
let baseSeconds = max(0, retryBaseDelay.timeInterval)
122+
guard baseSeconds > 0 else { return }
123+
124+
let delaySeconds = baseSeconds * pow(2.0, Double(attempt))
125+
let delayNanoseconds = UInt64(min(delaySeconds * 1_000_000_000, Double(UInt64.max)))
126+
try await Task.sleep(nanoseconds: delayNanoseconds)
66127
}
67128
}
68129

130+
private struct RetryableRequestError: Error {
131+
}
132+
69133
private struct APIEnvelope: Decodable {
70134
let result: [ConversionResult]
71135
let success: Bool

Sources/Swift2MD/Configuration.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,20 @@ public struct CloudflareCredentials: Sendable {
1818
public struct ConvertOptions: Sendable {
1919
/// Timeout applied to HTTP requests and resource downloads.
2020
public var timeout: Duration
21+
/// Number of retry attempts for retryable API failures (429/5xx and transient network errors).
22+
public var maxRetryCount: Int
23+
/// Base backoff delay used between retries. Delay doubles after each failed attempt.
24+
public var retryBaseDelay: Duration
2125

2226
/// Creates conversion options with an optional request timeout.
23-
public init(timeout: Duration = .seconds(60)) {
27+
public init(
28+
timeout: Duration = .seconds(60),
29+
maxRetryCount: Int = 2,
30+
retryBaseDelay: Duration = .milliseconds(300)
31+
) {
2432
self.timeout = timeout
33+
self.maxRetryCount = max(0, maxRetryCount)
34+
self.retryBaseDelay = retryBaseDelay
2535
}
2636
}
2737

Sources/Swift2MD/MarkdownConverter.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ public struct MarkdownConverter: Sendable {
1515
configuration.timeoutIntervalForResource = options.timeout.timeInterval
1616
let session = URLSession(configuration: configuration)
1717

18-
self.client = CloudflareClient(credentials: credentials, session: session, timeout: options.timeout)
18+
self.client = CloudflareClient(
19+
credentials: credentials,
20+
session: session,
21+
timeout: options.timeout,
22+
maxRetryCount: options.maxRetryCount,
23+
retryBaseDelay: options.retryBaseDelay
24+
)
1925
self.downloadSession = session
2026
}
2127

Sources/Swift2MD/Swift2MD.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,16 @@ public extension MarkdownConverter {
88
static func withCloudflare(
99
accountId: String,
1010
apiToken: String,
11-
timeout: Duration = .seconds(60)
11+
timeout: Duration = .seconds(60),
12+
maxRetryCount: Int = 2,
13+
retryBaseDelay: Duration = .milliseconds(300)
1214
) -> MarkdownConverter {
1315
let credentials = CloudflareCredentials(accountId: accountId, apiToken: apiToken)
14-
let options = ConvertOptions(timeout: timeout)
16+
let options = ConvertOptions(
17+
timeout: timeout,
18+
maxRetryCount: maxRetryCount,
19+
retryBaseDelay: retryBaseDelay
20+
)
1521
return MarkdownConverter(credentials: credentials, options: options)
1622
}
1723
}

0 commit comments

Comments
 (0)