├── Gemfile ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── question.md │ ├── documentation.md │ ├── bug_report.md │ └── feature_request.md ├── workflows │ ├── conventional-pr.yml │ ├── danger.yml │ ├── lint.yml │ ├── changelog.yml │ ├── release.yml │ └── ci.yml └── PULL_REQUEST_TEMPLATE.md ├── .spi.yml ├── Dangerfile ├── Resources └── typhoon.png ├── mise.toml ├── .gitignore ├── Sources └── Typhoon │ ├── Classes │ ├── Model │ │ └── RetryPolicyError.swift │ ├── RetrySequence │ │ ├── RetrySequence.swift │ │ └── Iterator │ │ │ └── RetryIterator.swift │ ├── Extensions │ │ └── DispatchTimeInterval+Double.swift │ ├── Strategy │ │ └── RetryPolicyStrategy.swift │ └── RetryPolicyService │ │ ├── IRetryPolicyService.swift │ │ └── RetryPolicyService.swift │ └── Typhoon.docc │ ├── Articles │ ├── installation.md │ ├── quick-start.md │ ├── best-practices.md │ └── advanced-retry-strategies.md │ ├── Overview.md │ └── Info.plist ├── mise └── tasks │ ├── lint │ └── install.sh ├── Package.swift ├── SECURITY.md ├── Package@swift-6.0.swift ├── Package@swift-6.1.swift ├── Package@swift-5.10.swift ├── renovate.json ├── LICENSE ├── Tests └── TyphoonTests │ └── UnitTests │ ├── RetryPolicyStrategyTests.swift │ ├── DispatchTimeIntervalTests.swift │ ├── RetrySequenceTests.swift │ └── RetryPolicyServiceTests.swift ├── codecov.yml ├── .swiftformat ├── Gemfile.lock ├── .swiftlint.yml ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md ├── README.md ├── cliff.toml └── CONTRIBUTING.md /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'danger' -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Global code owners 2 | * @space-code/engineering -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | metadata: 3 | authors: "Nikita Vasilev" -------------------------------------------------------------------------------- /Dangerfile: -------------------------------------------------------------------------------- 1 | danger.import_dangerfile(github: 'space-code/dangerfile') -------------------------------------------------------------------------------- /Resources/typhoon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/space-code/typhoon/HEAD/Resources/typhoon.png -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | git-cliff = "2.9.1" 3 | swiftlint = "0.62.2" 4 | swiftformat = "0.58.7" 5 | 6 | [settings] 7 | experimental = true 8 | 9 | [hooks] 10 | postinstall = "mise run install" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | 11 | .swiftpm/ -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 💬 Discussions 4 | url: https://github.com/space-code/typhoon/discussions 5 | about: Ask questions and discuss ideas with the community 6 | - name: 📚 Documentation 7 | url: https://github.com/space-code/typhoon 8 | about: Read the documentation -------------------------------------------------------------------------------- /Sources/Typhoon/Classes/Model/RetryPolicyError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typhoon 3 | // Copyright © 2023 Space Code. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | /// `RetryPolicyError` is the error type returned by Typhoon. 9 | public enum RetryPolicyError: Error { 10 | /// The retry limit for attempts to perform a request has been exceeded. 11 | case retryLimitExceeded 12 | } 13 | -------------------------------------------------------------------------------- /mise/tasks/lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | #MISE description="Lint the typhoon package using SwiftLint and SwiftFormat" 3 | #MISE usage flag "-f --fix" help="Fix the fixable issues" 4 | 5 | set -eo pipefail 6 | 7 | if [ "$usage_fix" = "true" ]; then 8 | swiftformat Sources Tests 9 | swiftlint lint --fix --strict --config .swiftlint.yml Sources Tests 10 | else 11 | swiftformat Sources Tests --lint 12 | swiftlint lint --strict --config .swiftlint.yml Sources Tests 13 | fi -------------------------------------------------------------------------------- /Sources/Typhoon/Typhoon.docc/Articles/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | A guide to installing the Typhoon package into your Swift project using Swift Package Manager. 4 | 5 | ## Swift Package Manager 6 | 7 | Add the Typhoon package to your project using Swift Package Manager: 8 | 9 | ```swift 10 | dependencies: [ 11 | .package(url: "https://github.com/space-code/typhoon.git", from: "1.3.0") 12 | ] 13 | ``` 14 | 15 | Or add it through Xcode: 16 | 17 | 1. File > Add Package Dependencies 18 | 2. Enter package URL: `https://github.com/space-code/typhoon.git` 19 | 3. Select version requirements 20 | -------------------------------------------------------------------------------- /Sources/Typhoon/Typhoon.docc/Overview.md: -------------------------------------------------------------------------------- 1 | # ``Typhoon`` 2 | 3 | @Metadata { 4 | @TechnologyRoot 5 | } 6 | 7 | ## Overview 8 | 9 | Typhoon is a modern, lightweight Swift framework that provides elegant and robust retry policies for asynchronous operations. Built with Swift's async/await concurrency model, it helps you handle transient failures gracefully with configurable retry strategies. 10 | 11 | ## Topics 12 | 13 | ### Essentials 14 | 15 | - ``RetryPolicyService`` 16 | - ``RetryPolicyStrategy`` 17 | 18 | ### Articles 19 | 20 | - 21 | - 22 | - 23 | - 24 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Typhoon", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | .tvOS(.v13), 12 | .watchOS(.v6), 13 | .visionOS(.v1), 14 | ], 15 | products: [ 16 | .library(name: "Typhoon", targets: ["Typhoon"]), 17 | ], 18 | targets: [ 19 | .target(name: "Typhoon", dependencies: []), 20 | .testTarget(name: "TyphoonTests", dependencies: ["Typhoon"]), 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Security Vulnerabilities 2 | 3 | This software is built with security and data privacy in mind to ensure your data is safe. We are grateful for security researchers and users reporting a vulnerability to us, first. To ensure that your request is handled in a timely manner and non-disclosure of vulnerabilities can be assured, please follow the below guideline. 4 | 5 | **Please do not report security vulnerabilities directly on GitHub. GitHub Issues can be publicly seen and therefore would result in a direct disclosure.** 6 | 7 | * Please address questions about data privacy, security concepts, and other media requests to the nv3212@gmail.com mailbox. -------------------------------------------------------------------------------- /Package@swift-6.0.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Typhoon", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | .tvOS(.v13), 12 | .watchOS(.v6), 13 | .visionOS(.v1), 14 | ], 15 | products: [ 16 | .library(name: "Typhoon", targets: ["Typhoon"]), 17 | ], 18 | targets: [ 19 | .target(name: "Typhoon", dependencies: []), 20 | .testTarget(name: "TyphoonTests", dependencies: ["Typhoon"]), 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /Package@swift-6.1.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Typhoon", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | .tvOS(.v13), 12 | .watchOS(.v6), 13 | .visionOS(.v1), 14 | ], 15 | products: [ 16 | .library(name: "Typhoon", targets: ["Typhoon"]), 17 | ], 18 | targets: [ 19 | .target(name: "Typhoon", dependencies: []), 20 | .testTarget(name: "TyphoonTests", dependencies: ["Typhoon"]), 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /Package@swift-5.10.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Typhoon", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | .tvOS(.v13), 12 | .watchOS(.v6), 13 | .visionOS(.v1), 14 | ], 15 | products: [ 16 | .library(name: "Typhoon", targets: ["Typhoon"]), 17 | ], 18 | targets: [ 19 | .target(name: "Typhoon", dependencies: []), 20 | .testTarget(name: "TyphoonTests", dependencies: ["Typhoon"]), 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /.github/workflows/conventional-pr.yml: -------------------------------------------------------------------------------- 1 | name: conventional-pr 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | types: 7 | - opened 8 | - edited 9 | - synchronize 10 | permissions: 11 | contents: read 12 | pull-requests: read 13 | statuses: write 14 | jobs: 15 | lint-pr: 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 15 18 | if: ${{ !startsWith(github.event.head_commit.message, '[Release]') }} 19 | steps: 20 | - uses: actions/checkout@v6 21 | - uses: amannn/action-semantic-pull-request@v6 22 | with: 23 | requireScope: false 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /Sources/Typhoon/Classes/RetrySequence/RetrySequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typhoon 3 | // Copyright © 2023 Space Code. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | /// An object that represents a sequence for retry policies. 9 | struct RetrySequence: Sequence { 10 | // MARK: Properties 11 | 12 | /// The strategy defining the behavior of the retry policy. 13 | private let strategy: RetryPolicyStrategy 14 | 15 | // MARK: Initialization 16 | 17 | /// Creates a new `RetrySequence` instance. 18 | /// 19 | /// - Parameter strategy: The strategy defining the behavior of the retry policy. 20 | init(strategy: RetryPolicyStrategy) { 21 | self.strategy = strategy 22 | } 23 | 24 | // MARK: Sequence 25 | 26 | func makeIterator() -> RetryIterator { 27 | RetryIterator(strategy: strategy) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ❓ Question 3 | about: Ask a question about the project 4 | title: '[QUESTION] ' 5 | labels: question 6 | assignees: '' 7 | --- 8 | 9 | ## Question 10 | 11 | 12 | 13 | ## Context 14 | 15 | 16 | 17 | ## What I've Already Tried 18 | 19 | 20 | 21 | - [ ] Searched existing issues 22 | - [ ] Checked documentation 23 | - [ ] Searched online resources 24 | - [ ] Attempted to solve it myself 25 | 26 | ## Related Documentation 27 | 28 | 29 | 30 | ## Environment (if applicable) 31 | 32 | - **OS**: 33 | - **Version**: 34 | 35 | ## Additional Information 36 | 37 | -------------------------------------------------------------------------------- /.github/workflows/danger.yml: -------------------------------------------------------------------------------- 1 | name: danger 2 | 3 | on: 4 | pull_request: 5 | types: [synchronize, opened, reopened, labeled, unlabeled, edited] 6 | 7 | env: 8 | LC_CTYPE: en_US.UTF-8 9 | LANG: en_US.UTF-8 10 | 11 | permissions: 12 | contents: read 13 | pull-requests: write 14 | 15 | jobs: 16 | run-danger: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: ruby setup 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: 3.4.7 23 | bundler-cache: true 24 | - name: Checkout code 25 | uses: actions/checkout@v6 26 | - name: Setup gems 27 | run: | 28 | gem install bundler 29 | bundle config set clean true 30 | bundle config set path 'vendor/bundle' 31 | bundle install 32 | - name: danger 33 | env: 34 | DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} 35 | run: bundle exec danger --verbose -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | ":semanticCommits", 5 | ":disableDependencyDashboard", 6 | "config:recommended" 7 | ], 8 | "packageRules": [ 9 | { 10 | "matchUpdateTypes": ["major"], 11 | "labels": ["major"], 12 | "semanticCommitScope": "deps" 13 | }, 14 | { 15 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 16 | "automerge": true, 17 | "automergeType": "pr", 18 | "automergeStrategy": "auto", 19 | "semanticCommitScope": "deps", 20 | "labels": ["minor-patch"] 21 | }, 22 | { 23 | "matchManagers": ["swift"], 24 | "semanticCommitScope": "spm", 25 | "labels": ["swift", "spm"] 26 | }, 27 | { 28 | "matchManagers": ["github-actions"], 29 | "labels": ["github-actions", "ci"] 30 | } 31 | ], 32 | "lockFileMaintenance": { 33 | "enabled": true, 34 | "automerge": true 35 | } 36 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Space Code 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/Typhoon/Typhoon.docc/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CDAppleDefaultAvailability 6 | 7 | Typhoon 8 | 9 | 10 | name 11 | visionOS 12 | version 13 | 1.0 14 | 15 | 16 | name 17 | watchOS 18 | version 19 | 6.0 20 | 21 | 22 | name 23 | iOS 24 | version 25 | 13.0 26 | 27 | 28 | name 29 | visionOS 30 | version 31 | 1.0 32 | 33 | 34 | name 35 | tvOS 36 | version 37 | 13.0 38 | 39 | 40 | name 41 | macOS 42 | version 43 | 10.15 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | paths: 9 | - "Sources/**" 10 | - ".github/workflows/ci.yml" 11 | - "Tests/**" 12 | 13 | concurrency: 14 | group: lint-${{ github.head_ref }} 15 | cancel-in-progress: true 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | lint: 22 | name: lint 23 | runs-on: macos-15 24 | steps: 25 | - uses: actions/checkout@v6 26 | with: 27 | fetch-depth: 0 28 | - uses: jdx/mise-action@v3 29 | - name: Run 30 | run: mise run lint 31 | 32 | discover-typos: 33 | name: discover-typos 34 | runs-on: macos-15 35 | env: 36 | DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer 37 | steps: 38 | - uses: actions/checkout@v6 39 | 40 | - name: Set up Python environment 41 | run: | 42 | python3 -m venv .venv 43 | source .venv/bin/activate 44 | pip install --upgrade pip 45 | pip install codespell 46 | 47 | - name: Discover typos 48 | run: | 49 | source .venv/bin/activate 50 | codespell --ignore-words-list="hart,inout,msdos,sur" --skip="./.build/*,./.git/*" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 📝 Documentation 3 | about: Suggest improvements or report issues in documentation 4 | title: '[DOCS] ' 5 | labels: documentation 6 | assignees: '' 7 | --- 8 | 9 | ## Documentation Issue 10 | 11 | 12 | 13 | ## Location 14 | 15 | 16 | 17 | **Page/File**: 18 | **Section**: 19 | 20 | ## Type of Change 21 | 22 | - [ ] Typo or grammar fix 23 | - [ ] Inaccurate or outdated information 24 | - [ ] Missing documentation 25 | - [ ] Unclear or confusing explanation 26 | - [ ] Code example improvement 27 | - [ ] New documentation needed 28 | 29 | ## Current Content 30 | 31 | 32 | 33 | ``` 34 | 35 | ``` 36 | 37 | ## Proposed Changes 38 | 39 | 40 | 41 | ``` 42 | 43 | ``` 44 | 45 | ## Why This Matters 46 | 47 | 48 | 49 | ## Additional Context 50 | 51 | 52 | 53 | ## Related Issues / PRs 54 | 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug Report 3 | about: Report a bug to help us improve 4 | title: '[BUG] ' 5 | labels: bug, needs-triage 6 | assignees: '' 7 | --- 8 | 9 | ## Bug Description 10 | 11 | 12 | 13 | ## Steps to Reproduce 14 | 15 | 16 | 17 | 1. Go to '...' 18 | 2. Click on '...' 19 | 3. Scroll down to '...' 20 | 4. See error 21 | 22 | ## Expected Behavior 23 | 24 | 25 | 26 | ## Actual Behavior 27 | 28 | 29 | 30 | ## Screenshots / Logs 31 | 32 | 33 | 34 | ``` 35 | 36 | ``` 37 | 38 | ## Environment 39 | 40 | 41 | 42 | - **OS**: [e.g., macOS 14.0, iOS 17.0, watchOS 9.0] 43 | - **Version**: [e.g., 1.2.3] 44 | 45 | ## Additional Context 46 | 47 | 48 | 49 | ## Possible Solution 50 | 51 | 52 | 53 | ## Related Issues 54 | 55 | 56 | 57 | --- 58 | 59 | **Priority**: 60 | **Impact**: -------------------------------------------------------------------------------- /Tests/TyphoonTests/UnitTests/RetryPolicyStrategyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typhoon 3 | // Copyright © 2023 Space Code. All rights reserved. 4 | // 5 | 6 | import Typhoon 7 | import XCTest 8 | 9 | // MARK: - RetryPolicyStrategyTests 10 | 11 | final class RetryPolicyStrategyTests: XCTestCase { 12 | // MARK: Tests 13 | 14 | func test_thatRetryPolicyStrategyReturnsDuration_whenTypeIsConstant() { 15 | // when 16 | let duration = RetryPolicyStrategy.constant(retry: .retry, duration: .second).duration 17 | 18 | // then 19 | XCTAssertEqual(duration, .second) 20 | } 21 | 22 | func test_thatRetryPolicyStrategyReturnsDuration_whenTypeIsExponential() { 23 | // when 24 | let duration = RetryPolicyStrategy.exponential(retry: .retry, duration: .second).duration 25 | 26 | // then 27 | XCTAssertEqual(duration, .second) 28 | } 29 | 30 | func test_thatRetryPolicyStrategyReturnsDuration_whenTypeIsExponentialWithJitter() { 31 | // when 32 | let duration = RetryPolicyStrategy.exponentialWithJitter(retry: .retry, duration: .second).duration 33 | 34 | // then 35 | XCTAssertEqual(duration, .second) 36 | } 37 | } 38 | 39 | // MARK: Constants 40 | 41 | private extension Int { 42 | static let retry = 5 43 | } 44 | 45 | private extension DispatchTimeInterval { 46 | static let second = DispatchTimeInterval.seconds(1) 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Typhoon/Classes/Extensions/DispatchTimeInterval+Double.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typhoon 3 | // Copyright © 2023 Space Code. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | extension DispatchTimeInterval { 9 | /// Converts a `DispatchTimeInterval` value into seconds represented as `Double`. 10 | /// 11 | /// This computed property normalizes all supported `DispatchTimeInterval` cases 12 | /// (`seconds`, `milliseconds`, `microseconds`, `nanoseconds`) into a single 13 | /// unit — **seconds** — which simplifies time calculations and conversions. 14 | /// 15 | /// For example: 16 | /// - `.seconds(2)` → `2.0` 17 | /// - `.milliseconds(500)` → `0.5` 18 | /// - `.microseconds(1_000)` → `0.001` 19 | /// - `.nanoseconds(1_000_000_000)` → `1.0` 20 | /// 21 | /// - Returns: The interval expressed in seconds as `Double`, 22 | /// or `nil` if the interval represents `.never` or an unknown case. 23 | var double: Double? { 24 | switch self { 25 | case let .seconds(value): 26 | return Double(value) 27 | case let .milliseconds(value): 28 | return Double(value) * 1e-3 29 | case let .microseconds(value): 30 | return Double(value) * 1e-6 31 | case let .nanoseconds(value): 32 | return Double(value) * 1e-9 33 | case .never: 34 | return nil 35 | @unknown default: 36 | return nil 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | # Require CI to pass to show coverage, default yes 3 | require_ci_to_pass: yes 4 | notify: 5 | # Codecov should wait for all CI statuses to complete, default yes 6 | wait_for_ci: yes 7 | 8 | coverage: 9 | # Coverage precision range 0-5, default 2 10 | precision: 2 11 | 12 | # Direction to round the coverage value - up, down, nearest, default down 13 | round: nearest 14 | 15 | # Value range for red...green, default 70...100 16 | range: "70...90" 17 | 18 | status: 19 | # Overall project coverage, compare against pull request base 20 | project: 21 | default: 22 | # The required coverage value 23 | target: 50% 24 | 25 | # The leniency in hitting the target. Allow coverage to drop by X% 26 | threshold: 5% 27 | 28 | # Only measure lines adjusted in the pull request or single commit, if the commit in not in the pr 29 | patch: 30 | default: 31 | # The required coverage value 32 | target: 85% 33 | 34 | # Allow coverage to drop by X% 35 | threshold: 50% 36 | changes: no 37 | 38 | comment: 39 | # Pull request Codecov comment format. 40 | # diff: coverage diff of the pull request 41 | # files: a list of files impacted by the pull request (coverage changes, file is new or removed) 42 | layout: "diff, files" 43 | 44 | # Update Codecov comment, if exists. Otherwise post new 45 | behavior: default 46 | 47 | # If true, only post the Codecov comment if coverage changes 48 | require_changes: false -------------------------------------------------------------------------------- /mise/tasks/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo "🔧 Installing git hooks..." 6 | 7 | # Find git repository root 8 | GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) 9 | 10 | if [ -z "$GIT_ROOT" ]; then 11 | echo "❌ Error: Not a git repository" 12 | exit 1 13 | fi 14 | 15 | echo "📁 Git root: $GIT_ROOT" 16 | 17 | # Create hooks directory if it doesn't exist 18 | mkdir -p "$GIT_ROOT/.git/hooks" 19 | 20 | # Create pre-commit hook 21 | cat > "$GIT_ROOT/.git/hooks/pre-commit" <<'HOOK_EOF' 22 | #!/bin/bash 23 | 24 | echo "🔍 Running linters..." 25 | 26 | echo "📝 Formatting staged Swift files..." 27 | git diff --diff-filter=d --staged --name-only | grep -e '\.swift$' | while read line; do 28 | if [[ $line == *"/Generated"* ]]; then 29 | echo "⏭️ Skipping generated file: $line" 30 | else 31 | echo "✨ Formatting: $line" 32 | mise exec swiftformat -- swiftformat "${line}" 33 | git add "$line" 34 | fi 35 | done 36 | 37 | if ! mise run lint; then 38 | echo "❌ Lint failed. Please fix the issues before committing." 39 | echo "💡 Tip: Run 'mise run format' to auto-fix some issues" 40 | echo "⚠️ To skip this hook, use: git commit --no-verify" 41 | exit 1 42 | fi 43 | 44 | echo "✅ All checks passed!" 45 | exit 0 46 | HOOK_EOF 47 | 48 | chmod +x "$GIT_ROOT/.git/hooks/pre-commit" 49 | 50 | echo "✅ Git hooks installed successfully!" 51 | echo "📍 Hook location: $GIT_ROOT/.git/hooks/pre-commit" 52 | echo "" 53 | echo "Pre-commit hook will:" 54 | echo " 1. Format staged Swift files (except /Generated)" 55 | echo " 2. Run mise lint" 56 | echo "" 57 | echo "To skip the hook, use: git commit --no-verify" 58 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ✨ Feature Request 3 | about: Suggest a new feature or enhancement 4 | title: '[FEATURE] ' 5 | labels: enhancement, needs-triage 6 | assignees: '' 7 | --- 8 | 9 | ## Feature Summary 10 | 11 | 12 | 13 | ## Motivation 14 | 15 | 16 | 17 | **Is your feature request related to a problem?** 18 | 19 | 20 | ## Proposed Solution 21 | 22 | 23 | 24 | ## Alternative Solutions 25 | 26 | 27 | 28 | ## Use Cases 29 | 30 | 31 | 32 | 1. 33 | 2. 34 | 3. 35 | 36 | ## Benefits 37 | 38 | 39 | 40 | - 41 | - 42 | - 43 | 44 | ## Implementation Details 45 | 46 | 47 | 48 | ``` 49 | 50 | ``` 51 | 52 | ## Mockups / Examples 53 | 54 | 55 | 56 | ## Potential Drawbacks 57 | 58 | 59 | 60 | ## Additional Context 61 | 62 | 63 | 64 | ## Related Issues / PRs 65 | 66 | 67 | 68 | --- 69 | 70 | **Priority**: 71 | **Effort Estimate**: 72 | **Would you like to implement this feature?**: -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: update-changelog 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: 7 | contents: write 8 | jobs: 9 | changelog: 10 | name: Update CHANGELOG 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 15 13 | if: | 14 | !startsWith(github.event.head_commit.message, '[Release]') && 15 | !startsWith(github.event.head_commit.message, 'chore(changelog): update CHANGELOG.md') && 16 | github.event.head_commit.author.name != 'github-actions[bot]' 17 | steps: 18 | - uses: actions/checkout@v6 19 | with: 20 | fetch-depth: 0 21 | - uses: jdx/mise-action@v3 22 | with: 23 | experimental: true 24 | - name: Generate CHANGELOG.md 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | run: git cliff --config ./cliff.toml -o ./CHANGELOG.md 28 | - name: Check for CHANGELOG changes 29 | id: changelog-changes 30 | run: | 31 | if git diff --quiet CHANGELOG.md; then 32 | echo "No changes in CHANGELOG.md" 33 | echo "has-changes=false" >> $GITHUB_OUTPUT 34 | else 35 | echo "CHANGELOG.md has changes" 36 | echo "has-changes=true" >> $GITHUB_OUTPUT 37 | fi 38 | - name: Commit CHANGELOG 39 | uses: stefanzweifel/git-auto-commit-action@v7 40 | if: steps.changelog-changes.outputs.has-changes == 'true' 41 | with: 42 | commit_message: "chore(changelog): update CHANGELOG.md" 43 | commit_options: '--no-verify' 44 | file_pattern: CHANGELOG.md 45 | commit_user_name: github-actions[bot] 46 | commit_user_email: github-actions[bot]@users.noreply.github.com -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # Stream rules 2 | 3 | --swiftversion 5.10 4 | 5 | # Use 'swiftformat --options' to list all of the possible options 6 | 7 | --header "\nTyphoon\nCopyright © {created.year} Space Code. All rights reserved.\n//" 8 | 9 | --enable blankLinesBetweenScopes 10 | --enable blankLinesAtStartOfScope 11 | --enable blankLinesAtEndOfScope 12 | --enable blankLinesAroundMark 13 | --enable anyObjectProtocol 14 | --enable consecutiveBlankLines 15 | --enable consecutiveSpaces 16 | --enable duplicateImports 17 | --enable elseOnSameLine 18 | --enable emptyBraces 19 | --enable initCoderUnavailable 20 | --enable leadingDelimiters 21 | --enable numberFormatting 22 | --enable preferKeyPath 23 | --enable redundantBreak 24 | --enable redundantExtensionACL 25 | --enable redundantFileprivate 26 | --enable redundantGet 27 | --enable redundantInit 28 | --enable redundantLet 29 | --enable redundantLetError 30 | --enable redundantNilInit 31 | --enable redundantObjc 32 | --enable redundantParens 33 | --enable redundantPattern 34 | --enable redundantRawValues 35 | --enable redundantReturn 36 | --enable redundantSelf 37 | --enable redundantVoidReturnType 38 | --enable semicolons 39 | --enable sortImports 40 | --enable sortSwitchCases 41 | --enable spaceAroundBraces 42 | --enable spaceAroundBrackets 43 | --enable spaceAroundComments 44 | --enable spaceAroundGenerics 45 | --enable spaceAroundOperators 46 | --enable spaceInsideBraces 47 | --enable spaceInsideBrackets 48 | --enable spaceInsideComments 49 | --enable spaceInsideGenerics 50 | --enable spaceInsideParens 51 | --enable strongOutlets 52 | --enable strongifiedSelf 53 | --enable todos 54 | --enable trailingClosures 55 | --enable unusedArguments 56 | --enable void 57 | --enable markTypes 58 | --enable isEmpty 59 | 60 | # format options 61 | 62 | --wraparguments before-first 63 | --wrapcollections before-first 64 | --maxwidth 140 -------------------------------------------------------------------------------- /Tests/TyphoonTests/UnitTests/DispatchTimeIntervalTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typhoon 3 | // Copyright © 2023 Space Code. All rights reserved. 4 | // 5 | 6 | @testable import Typhoon 7 | import XCTest 8 | 9 | // MARK: - DispatchTimeIntervalTests 10 | 11 | final class DispatchTimeIntervalTests: XCTestCase { 12 | func test_thatDispatchTimeIntervalConvertsMillisecondsToDouble() { 13 | // given 14 | let interval = DispatchTimeInterval.milliseconds(.value) 15 | 16 | // when 17 | let result = interval.double 18 | 19 | // then 20 | XCTAssertEqual(result, Double(Int.value) * 1e-3) 21 | } 22 | 23 | func test_thatDispatchTimeIntervalConvertsSecondsToDouble() { 24 | // given 25 | let interval = DispatchTimeInterval.seconds(.value) 26 | 27 | // when 28 | let result = interval.double 29 | 30 | // then 31 | XCTAssertEqual(result, Double(Int.value)) 32 | } 33 | 34 | func test_thatDispatchTimeIntervalConvertsMicrosecondsToDouble() { 35 | // given 36 | let interval = DispatchTimeInterval.microseconds(.value) 37 | 38 | // when 39 | let result = interval.double 40 | 41 | // then 42 | XCTAssertEqual(result, Double(Int.value) * 1e-6) 43 | } 44 | 45 | func test_thatDispatchTimeIntervalConvertsNanosecondsToDouble() { 46 | // given 47 | let interval = DispatchTimeInterval.nanoseconds(.value) 48 | 49 | // when 50 | let result = interval.double 51 | 52 | // then 53 | XCTAssertEqual(result, Double(Int.value) * 1e-9) 54 | } 55 | 56 | func test_thatDispatchTimeIntervalReturnsNil_whenIntervalIsEqualToNever() { 57 | // given 58 | let interval = DispatchTimeInterval.never 59 | 60 | // when 61 | let result = interval.double 62 | 63 | // then 64 | XCTAssertNil(result) 65 | } 66 | } 67 | 68 | // MARK: - Constants 69 | 70 | private extension Int { 71 | static let value = 1000 72 | } 73 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Type of Change 6 | 7 | 8 | 9 | - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) 10 | - [ ] ✨ New feature (non-breaking change which adds functionality) 11 | - [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | - [ ] 📝 Documentation update 13 | - [ ] 🎨 Code style update (formatting, renaming) 14 | - [ ] ♻️ Code refactoring (no functional changes) 15 | - [ ] ⚡ Performance improvement 16 | - [ ] ✅ Test update 17 | - [ ] 🔧 Build configuration change 18 | - [ ] 🔒 Security update 19 | 20 | ## Related Issues 21 | 22 | 23 | 24 | Fixes # 25 | Related to # 26 | 27 | ## Changes Made 28 | 29 | 30 | 31 | - 32 | - 33 | - 34 | 35 | ## Screenshots / Videos 36 | 37 | 38 | 39 | ## Testing 40 | 41 | 42 | 43 | ### Test Configuration 44 | 45 | - **OS**: 46 | 47 | ### Test Checklist 48 | 49 | - [ ] Unit tests pass locally 50 | - [ ] Integration tests pass locally 51 | - [ ] Manual testing completed 52 | - [ ] Edge cases covered 53 | 54 | ## Checklist 55 | 56 | 57 | 58 | - [ ] My code follows the project's code style guidelines 59 | - [ ] I have performed a self-review of my code 60 | - [ ] I have commented my code, particularly in hard-to-understand areas 61 | - [ ] I have made corresponding changes to the documentation 62 | - [ ] My changes generate no new warnings 63 | - [ ] I have added tests that prove my fix is effective or that my feature works 64 | - [ ] New and existing unit tests pass locally with my changes 65 | - [ ] Any dependent changes have been merged and published 66 | 67 | ## Breaking Changes 68 | 69 | 70 | 71 | ## Migration Guide 72 | 73 | 74 | 75 | ## Additional Notes 76 | 77 | 78 | 79 | --- 80 | 81 | **Reviewers**: @ -------------------------------------------------------------------------------- /Sources/Typhoon/Classes/Strategy/RetryPolicyStrategy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typhoon 3 | // Copyright © 2023 Space Code. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | /// A strategy used to define different retry policies. 9 | public enum RetryPolicyStrategy: Sendable { 10 | /// A retry strategy with a constant number of attempts and fixed duration between retries. 11 | /// 12 | /// - Parameters: 13 | /// - retry: The number of retry attempts. 14 | /// - duration: The initial duration between retries. 15 | case constant(retry: Int, duration: DispatchTimeInterval) 16 | 17 | /// A retry strategy with an exponential increase in duration between retries. 18 | /// 19 | /// - Parameters: 20 | /// - retry: The number of retry attempts. 21 | /// - multiplier: The multiplier for calculating the exponential backoff duration (default is 2). 22 | /// - duration: The initial duration between retries. 23 | case exponential(retry: Int, multiplier: Double = 2, duration: DispatchTimeInterval) 24 | 25 | /// A retry strategy with exponential increase in duration between retries and added jitter. 26 | /// 27 | /// - Parameters: 28 | /// - retry: The number of retry attempts. 29 | /// - jitterFactor: The factor to control the amount of jitter (default is 0.1). 30 | /// - maxInterval: The maximum allowed interval between retries (default is 60 seconds). 31 | /// - multiplier: The multiplier for calculating the exponential backoff duration (default is 2). 32 | /// - duration: The initial duration between retries. 33 | case exponentialWithJitter( 34 | retry: Int, 35 | jitterFactor: Double = 0.1, 36 | maxInterval: DispatchTimeInterval? = .seconds(60), 37 | multiplier: Double = 2, 38 | duration: DispatchTimeInterval 39 | ) 40 | 41 | /// The number of retry attempts based on the strategy. 42 | public var retries: Int { 43 | switch self { 44 | case let .constant(retry, _): 45 | retry 46 | case let .exponential(retry, _, _): 47 | retry 48 | case let .exponentialWithJitter(retry, _, _, _, _): 49 | retry 50 | } 51 | } 52 | 53 | /// The time duration between retries based on the strategy. 54 | public var duration: DispatchTimeInterval { 55 | switch self { 56 | case let .constant(_, duration): 57 | duration 58 | case let .exponential(_, _, duration): 59 | duration 60 | case let .exponentialWithJitter(_, _, _, _, duration): 61 | duration 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/Typhoon/Classes/RetryPolicyService/IRetryPolicyService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typhoon 3 | // Copyright © 2023 Space Code. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | // MARK: - IRetryPolicyService 9 | 10 | /// A type that defines a service for retry policies 11 | public protocol IRetryPolicyService: Sendable { 12 | /// Retries a closure with a given strategy. 13 | /// 14 | /// - Parameters: 15 | /// - strategy: The strategy defining the behavior of the retry policy. 16 | /// - onFailure: An optional closure called on each failure to handle or log errors. 17 | /// - closure: The closure that will be retried based on the specified strategy. 18 | /// 19 | /// - Returns: The result of the closure's execution after retrying based on the policy. 20 | func retry( 21 | strategy: RetryPolicyStrategy?, 22 | onFailure: (@Sendable (Error) async -> Bool)?, 23 | _ closure: @Sendable () async throws -> T 24 | ) async throws -> T 25 | } 26 | 27 | public extension IRetryPolicyService { 28 | /// Retries a closure with a given strategy. 29 | /// 30 | /// - Parameters: 31 | /// - closure: The closure that will be retried based on the specified strategy. 32 | /// 33 | /// - Returns: The result of the closure's execution after retrying based on the policy. 34 | func retry(_ closure: @Sendable () async throws -> T) async throws -> T { 35 | try await retry(strategy: nil, onFailure: nil, closure) 36 | } 37 | 38 | /// Retries a closure with a given strategy. 39 | /// 40 | /// - Parameters: 41 | /// - strategy: The strategy defining the behavior of the retry policy. 42 | /// - closure: The closure that will be retried based on the specified strategy. 43 | /// 44 | /// - Returns: The result of the closure's execution after retrying based on the policy. 45 | func retry(strategy: RetryPolicyStrategy?, _ closure: @Sendable () async throws -> T) async throws -> T { 46 | try await retry(strategy: strategy, onFailure: nil, closure) 47 | } 48 | 49 | /// Retries a closure with a given strategy. 50 | /// 51 | /// - Parameters: 52 | /// - onFailure: An optional closure called on each failure to handle or log errors. 53 | /// - closure: The closure that will be retried based on the specified strategy. 54 | /// 55 | /// - Returns: The result of the closure's execution after retrying based on the policy. 56 | func retry(_ closure: @Sendable () async throws -> T, onFailure: (@Sendable (Error) async -> Bool)?) async throws -> T { 57 | try await retry(strategy: nil, onFailure: onFailure, closure) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (8.1.1) 5 | base64 6 | bigdecimal 7 | concurrent-ruby (~> 1.0, >= 1.3.1) 8 | connection_pool (>= 2.2.5) 9 | drb 10 | i18n (>= 1.6, < 2) 11 | json 12 | logger (>= 1.4.2) 13 | minitest (>= 5.1) 14 | securerandom (>= 0.3) 15 | tzinfo (~> 2.0, >= 2.0.5) 16 | uri (>= 0.13.1) 17 | addressable (2.8.8) 18 | public_suffix (>= 2.0.2, < 8.0) 19 | base64 (0.3.0) 20 | bigdecimal (3.3.1) 21 | claide (1.1.0) 22 | claide-plugins (0.9.2) 23 | cork 24 | nap 25 | open4 (~> 1.3) 26 | colored2 (3.1.2) 27 | concurrent-ruby (1.3.6) 28 | connection_pool (3.0.2) 29 | cork (0.3.0) 30 | colored2 (~> 3.1) 31 | danger (9.5.3) 32 | base64 (~> 0.2) 33 | claide (~> 1.0) 34 | claide-plugins (>= 0.9.2) 35 | colored2 (>= 3.1, < 5) 36 | cork (~> 0.1) 37 | faraday (>= 0.9.0, < 3.0) 38 | faraday-http-cache (~> 2.0) 39 | git (>= 1.13, < 3.0) 40 | kramdown (>= 2.5.1, < 3.0) 41 | kramdown-parser-gfm (~> 1.0) 42 | octokit (>= 4.0) 43 | pstore (~> 0.1) 44 | terminal-table (>= 1, < 5) 45 | drb (2.2.3) 46 | faraday (2.14.0) 47 | faraday-net_http (>= 2.0, < 3.5) 48 | json 49 | logger 50 | faraday-http-cache (2.5.1) 51 | faraday (>= 0.8) 52 | faraday-net_http (3.4.2) 53 | net-http (~> 0.5) 54 | git (2.3.3) 55 | activesupport (>= 5.0) 56 | addressable (~> 2.8) 57 | process_executer (~> 1.1) 58 | rchardet (~> 1.8) 59 | i18n (1.14.7) 60 | concurrent-ruby (~> 1.0) 61 | json (2.18.0) 62 | kramdown (2.5.1) 63 | rexml (>= 3.3.9) 64 | kramdown-parser-gfm (1.1.0) 65 | kramdown (~> 2.0) 66 | logger (1.7.0) 67 | minitest (5.27.0) 68 | nap (1.1.0) 69 | net-http (0.8.0) 70 | uri (>= 0.11.1) 71 | octokit (10.0.0) 72 | faraday (>= 1, < 3) 73 | sawyer (~> 0.9) 74 | open4 (1.3.4) 75 | process_executer (1.3.0) 76 | pstore (0.2.0) 77 | public_suffix (7.0.0) 78 | rchardet (1.10.0) 79 | rexml (3.4.4) 80 | sawyer (0.9.3) 81 | addressable (>= 2.3.5) 82 | faraday (>= 0.17.3, < 3) 83 | securerandom (0.4.1) 84 | terminal-table (4.0.0) 85 | unicode-display_width (>= 1.1.1, < 4) 86 | tzinfo (2.0.6) 87 | concurrent-ruby (~> 1.0) 88 | unicode-display_width (3.2.0) 89 | unicode-emoji (~> 4.1) 90 | unicode-emoji (4.1.0) 91 | uri (1.1.1) 92 | 93 | PLATFORMS 94 | x86_64-darwin-22 95 | x86_64-linux 96 | 97 | DEPENDENCIES 98 | danger 99 | 100 | BUNDLED WITH 101 | 2.4.21 102 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - Tests 3 | - Package.swift 4 | - Package@swift-5.10.swift 5 | - Package@swift-6.0.swift 6 | - Package@swift-6.1.swift 7 | - Package@swift-6.2.swift 8 | - .build 9 | 10 | # Rules 11 | 12 | disabled_rules: 13 | - trailing_comma 14 | - todo 15 | - opening_brace 16 | 17 | opt_in_rules: # some rules are only opt-in 18 | - array_init 19 | - attributes 20 | - closure_body_length 21 | - closure_end_indentation 22 | - closure_spacing 23 | - collection_alignment 24 | - contains_over_filter_count 25 | - contains_over_filter_is_empty 26 | - contains_over_first_not_nil 27 | - contains_over_range_nil_comparison 28 | - convenience_type 29 | - discouraged_object_literal 30 | - discouraged_optional_boolean 31 | - empty_collection_literal 32 | - empty_count 33 | - empty_string 34 | - empty_xctest_method 35 | - explicit_init 36 | - fallthrough 37 | - fatal_error_message 38 | - file_name 39 | - file_types_order 40 | - first_where 41 | - flatmap_over_map_reduce 42 | - force_unwrapping 43 | - ibinspectable_in_extension 44 | - identical_operands 45 | - implicit_return 46 | - joined_default_parameter 47 | - last_where 48 | - legacy_multiple 49 | - legacy_random 50 | - literal_expression_end_indentation 51 | - lower_acl_than_parent 52 | - multiline_arguments 53 | - multiline_function_chains 54 | - multiline_literal_brackets 55 | - multiline_parameters 56 | - multiline_parameters_brackets 57 | - no_space_in_method_call 58 | - operator_usage_whitespace 59 | - optional_enum_case_matching 60 | - orphaned_doc_comment 61 | - overridden_super_call 62 | - override_in_extension 63 | - pattern_matching_keywords 64 | - prefer_self_type_over_type_of_self 65 | - prefer_zero_over_explicit_init 66 | - prefixed_toplevel_constant 67 | - private_action 68 | - prohibited_super_call 69 | - quick_discouraged_call 70 | - quick_discouraged_focused_test 71 | - quick_discouraged_pending_test 72 | - reduce_into 73 | - redundant_nil_coalescing 74 | - redundant_objc_attribute 75 | - redundant_type_annotation 76 | - required_enum_case 77 | - single_test_class 78 | - sorted_first_last 79 | - sorted_imports 80 | - static_operator 81 | - strict_fileprivate 82 | - switch_case_on_newline 83 | - toggle_bool 84 | - unavailable_function 85 | - unneeded_parentheses_in_closure_argument 86 | - unowned_variable_capture 87 | - untyped_error_in_catch 88 | - vertical_parameter_alignment_on_call 89 | - vertical_whitespace_closing_braces 90 | - vertical_whitespace_opening_braces 91 | - xct_specific_matcher 92 | - yoda_condition 93 | 94 | force_cast: warning 95 | force_try: warning 96 | 97 | identifier_name: 98 | excluded: 99 | - id 100 | - URL 101 | 102 | analyzer_rules: 103 | - unused_import 104 | - unused_declaration 105 | 106 | line_length: 107 | warning: 130 108 | error: 200 109 | 110 | type_body_length: 111 | warning: 300 112 | error: 400 113 | 114 | file_length: 115 | warning: 500 116 | error: 1200 117 | 118 | function_body_length: 119 | warning: 30 120 | error: 50 121 | 122 | large_tuple: 123 | error: 3 124 | 125 | nesting: 126 | type_level: 127 | warning: 2 128 | statement_level: 129 | warning: 10 130 | 131 | 132 | type_name: 133 | max_length: 134 | warning: 40 135 | error: 50 136 | -------------------------------------------------------------------------------- /Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typhoon 3 | // Copyright © 2023 Space Code. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | // MARK: - RetryPolicyService 9 | 10 | /// `RetryPolicyService` provides a high-level API for retrying asynchronous 11 | /// operations using configurable retry strategies. 12 | /// 13 | /// The service encapsulates retry logic such as: 14 | /// - limiting the number of retry attempts, 15 | /// - applying delays between retries (e.g. fixed, exponential, or custom), 16 | /// - reacting to errors on each failed attempt. 17 | /// 18 | /// This class is typically used for retrying unstable operations like 19 | /// network requests, database calls, or interactions with external services. 20 | /// 21 | /// ### Example 22 | /// ```swift 23 | /// let strategy = RetryPolicyStrategy.exponential( 24 | /// maxAttempts: 3, 25 | /// initialDelay: .milliseconds(500) 26 | /// ) 27 | /// 28 | /// let retryService = RetryPolicyService(strategy: strategy) 29 | /// 30 | /// let data = try await retryService.retry( 31 | /// strategy: nil, 32 | /// onFailure: { error in 33 | /// print("Request failed with error: \(error)") 34 | /// 35 | /// // Return `true` to continue retrying, 36 | /// // or `false` to stop and rethrow the error. 37 | /// return true 38 | /// } 39 | /// ) { 40 | /// try await apiClient.fetchData() 41 | /// } 42 | /// ``` 43 | /// 44 | /// 45 | /// In this example: 46 | /// - The request will be retried up to 3 times. 47 | /// - The delay between retries grows exponentially. 48 | /// - Each failure is logged before the next attempt. 49 | /// - If all retries are exhausted, `RetryPolicyError.retryLimitExceeded` is thrown. 50 | /// 51 | /// - Note: You can override the default strategy per call by passing a custom 52 | /// `RetryPolicyStrategy` into the `retry` method. 53 | public final class RetryPolicyService { 54 | // MARK: Private 55 | 56 | /// The strategy defining the behavior of the retry policy. 57 | private let strategy: RetryPolicyStrategy 58 | 59 | // MARK: Initialization 60 | 61 | /// Creates a new `RetryPolicyService` instance. 62 | /// 63 | /// - Parameter strategy: The strategy defining the behavior of the retry policy. 64 | public init(strategy: RetryPolicyStrategy) { 65 | self.strategy = strategy 66 | } 67 | } 68 | 69 | // MARK: IRetryPolicyService 70 | 71 | extension RetryPolicyService: IRetryPolicyService { 72 | /// Retries a closure with a given strategy. 73 | /// 74 | /// - Parameters: 75 | /// - strategy: The strategy defining the behavior of the retry policy. 76 | /// - onFailure: An optional closure called on each failure to handle or log errors. 77 | /// - closure: The closure that will be retried based on the specified strategy. 78 | /// 79 | /// - Returns: The result of the closure's execution after retrying based on the policy. 80 | public func retry( 81 | strategy: RetryPolicyStrategy?, 82 | onFailure: (@Sendable (Error) async -> Bool)?, 83 | _ closure: @Sendable () async throws -> T 84 | ) async throws -> T { 85 | let effectiveStrategy = strategy ?? self.strategy 86 | 87 | var iterator = RetrySequence(strategy: effectiveStrategy).makeIterator() 88 | 89 | while true { 90 | do { 91 | return try await closure() 92 | } catch { 93 | let shouldContinue = await onFailure?(error) ?? true 94 | 95 | if !shouldContinue { 96 | throw error 97 | } 98 | 99 | guard let duration = iterator.next() else { 100 | throw RetryPolicyError.retryLimitExceeded 101 | } 102 | 103 | try Task.checkCancellation() 104 | 105 | try await Task.sleep(nanoseconds: duration) 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/Typhoon/Typhoon.docc/Articles/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | Get up and running with Typhoon in minutes. 4 | 5 | ## Overview 6 | 7 | Typhoon is a powerful retry policy framework for Swift that helps you handle transient failures gracefully. This guide will help you integrate Typhoon into your project and start using retry policies immediately. 8 | 9 | ## Basic Usage 10 | 11 | ### Your First Retry 12 | 13 | Import Typhoon and create a retry service with a simple constant strategy: 14 | 15 | ```swift 16 | import Typhoon 17 | 18 | let retryService = RetryPolicyService( 19 | strategy: .constant(retry: 3, duration: .seconds(1)) 20 | ) 21 | 22 | do { 23 | let result = try await retryService.retry { 24 | try await fetchDataFromAPI() 25 | } 26 | print("Success: \(result)") 27 | } catch { 28 | print("Failed after 3 retries: \(error)") 29 | } 30 | ``` 31 | 32 | This will: 33 | - Try your operation immediately 34 | - If it fails, wait 1 second and retry 35 | - Repeat up to 3 times 36 | 37 | ### Network Request Example 38 | 39 | Here's a practical example with URLSession: 40 | 41 | ```swift 42 | import Foundation 43 | import Typhoon 44 | 45 | func fetchUser(id: String) async throws -> User { 46 | let retryService = RetryPolicyService( 47 | strategy: .exponential(retry: 3, duration: .milliseconds(500)) 48 | ) 49 | 50 | return try await retryService.retry { 51 | let url = URL(string: "https://api.example.com/users/\(id)")! 52 | let (data, response) = try await URLSession.shared.data(from: url) 53 | 54 | guard let httpResponse = response as? HTTPURLResponse, 55 | httpResponse.statusCode == 200 else { 56 | throw NetworkError.invalidResponse 57 | } 58 | 59 | return try JSONDecoder().decode(User.self, from: data) 60 | } 61 | } 62 | ``` 63 | 64 | ## Choosing a Strategy 65 | 66 | Typhoon provides three retry strategies: 67 | 68 | ### Constant Strategy 69 | 70 | Best for predictable, fixed delays: 71 | 72 | ```swift 73 | // Retry 5 times with 2 seconds between attempts 74 | .constant(retry: 5, duration: .seconds(2)) 75 | ``` 76 | 77 | **Timeline:** 0s (initial) → 2s → 2s → 2s → 2s → 2s 78 | 79 | ### Exponential Strategy 80 | 81 | Ideal for backing off from failing services: 82 | 83 | ```swift 84 | // Retry 4 times with exponentially increasing delays 85 | .exponential(retry: 4, multiplier: 2.0, duration: .seconds(1)) 86 | ``` 87 | 88 | **Timeline:** 0s (initial) → 1s → 2s → 4s → 8s 89 | 90 | ### Exponential with Jitter 91 | 92 | Best for preventing thundering herd problems: 93 | 94 | ```swift 95 | // Retry with exponential backoff, jitter, and cap 96 | .exponentialWithJitter( 97 | retry: 5, 98 | jitterFactor: 0.2, 99 | maxInterval: .seconds(30), 100 | multiplier: 2.0, 101 | duration: .seconds(1) 102 | ) 103 | ``` 104 | 105 | **Timeline:** 0s (initial) → ~1s → ~2s → ~4s → ~8s → ~16s (with randomization) 106 | 107 | ## Common Patterns 108 | 109 | ### Wrapping in a Service Class 110 | 111 | Create a reusable service with built-in retry logic: 112 | 113 | ```swift 114 | import Typhoon 115 | 116 | class APIClient { 117 | private let retryService = RetryPolicyService( 118 | strategy: .exponential(retry: 3, duration: .milliseconds(500)) 119 | ) 120 | 121 | func get(endpoint: String) async throws -> T { 122 | try await retryService.retry { 123 | let url = URL(string: "https://api.example.com/\(endpoint)")! 124 | let (data, _) = try await URLSession.shared.data(from: url) 125 | return try JSONDecoder().decode(T.self, from: data) 126 | } 127 | } 128 | } 129 | 130 | // Usage 131 | let client = APIClient() 132 | let user: User = try await client.get(endpoint: "users/123") 133 | ``` 134 | 135 | ### Error Handling 136 | 137 | Handle specific errors after all retries are exhausted: 138 | 139 | ```swift 140 | do { 141 | let data = try await retryService.retry { 142 | try await performOperation() 143 | } 144 | // Handle success 145 | } catch NetworkError.serverUnavailable { 146 | print("Server is down") 147 | } catch NetworkError.timeout { 148 | print("Request timed out") 149 | } catch { 150 | print("Unexpected error: \(error)") 151 | } 152 | ``` 153 | 154 | ### Configuration for Different Scenarios 155 | 156 | ```swift 157 | // Quick operations (file I/O, cache access) 158 | let quickRetry = RetryPolicyService( 159 | strategy: .constant(retry: 3, duration: .milliseconds(100)) 160 | ) 161 | 162 | // Network requests 163 | let networkRetry = RetryPolicyService( 164 | strategy: .exponential(retry: 4, multiplier: 1.5, duration: .seconds(1)) 165 | ) 166 | 167 | // Critical operations (payments, data persistence) 168 | let criticalRetry = RetryPolicyService( 169 | strategy: .exponentialWithJitter( 170 | retry: 5, 171 | jitterFactor: 0.15, 172 | maxInterval: .seconds(60), 173 | multiplier: 2.0, 174 | duration: .seconds(2) 175 | ) 176 | ) 177 | ``` 178 | 179 | ## Next Steps 180 | 181 | Now that you have the basics, explore: 182 | 183 | - - Deep dive into retry strategies 184 | - - Learn best practices and patterns 185 | 186 | ## See Also 187 | 188 | - ``RetryPolicyService`` 189 | - ``RetryPolicyStrategy`` 190 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | nv3212@gmail.com. 64 | 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "The version to release" 8 | type: string 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: read 13 | statuses: write 14 | packages: write 15 | 16 | jobs: 17 | release: 18 | name: release 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 15 21 | outputs: 22 | has-changes: ${{ steps.check-changes.outputs.has-changes }} 23 | next-version: ${{ steps.next-version.outputs.NEXT_VERSION }} 24 | commit-hash: ${{ steps.auto-commit-action.outputs.commit_hash }} 25 | steps: 26 | - uses: actions/checkout@v6 27 | with: 28 | fetch-depth: 0 29 | - uses: jdx/mise-action@v3 30 | with: 31 | experimental: true 32 | - name: check for changes since last release 33 | id: check-changes 34 | run: | 35 | LAST_TAG=$(git tag -l | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1) 36 | if [ -z "$LAST_TAG" ]; then 37 | echo "No previous Typhoon releases found, will release" 38 | echo "has-changes=true" >> $GITHUB_OUTPUT 39 | else 40 | if [ -n "$(git diff --name-only ${LAST_TAG}..HEAD)" ]; then 41 | echo "Typhoon changes found since $LAST_TAG" 42 | echo "has-changes=true" >> $GITHUB_OUTPUT 43 | else 44 | echo "No Typhoon changes since $LAST_TAG" 45 | echo "has-changes=false" >> $GITHUB_OUTPUT 46 | fi 47 | fi 48 | - name: Get next version 49 | id: next-version 50 | if: steps.check-changes.outputs.has-changes == 'true' 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | run: | 54 | NEXT_VERSION=$(git cliff --config ./cliff.toml --bumped-version) 55 | echo "NEXT_VERSION=$NEXT_VERSION" >> "$GITHUB_OUTPUT" 56 | echo "Next Typhoon version will be: $NEXT_VERSION" 57 | - name: Update CHANGELOG.md 58 | if: steps.check-changes.outputs.has-changes == 'true' 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | run: git cliff --config ./cliff.toml --bump -o ./CHANGELOG.md 62 | - name: Update README.md version 63 | if: steps.check-changes.outputs.has-changes == 'true' 64 | run: | 65 | sed -i -E 's|https://github.com/space-code/typhoon.git", from: "[0-9]+\.[0-9]+\.[0-9]+"|https://github.com/space-code/typhoon.git", from: "'"${{ steps.next-version.outputs.NEXT_VERSION }}"'"|g' README.md 66 | echo "Updated README.md with version ${{ steps.next-version.outputs.NEXT_VERSION }}" 67 | - name: Get release notes 68 | id: release-notes 69 | if: steps.check-changes.outputs.has-changes == 'true' 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | run: | 73 | echo "RELEASE_NOTES<> "$GITHUB_OUTPUT" 74 | 75 | echo "All notable changes to this project will be documented in this file." >> "$GITHUB_OUTPUT" 76 | echo "" >> "$GITHUB_OUTPUT" 77 | echo "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)," >> "$GITHUB_OUTPUT" 78 | echo "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)." >> "$GITHUB_OUTPUT" 79 | echo "" >> "$GITHUB_OUTPUT" 80 | 81 | git cliff --config ./cliff.toml --tag ${{ steps.next-version.outputs.NEXT_VERSION }} --unreleased --strip header | awk 'NF{p=1} p' | tail -n +2 >> "$GITHUB_OUTPUT" 82 | 83 | echo "EOF" >> "$GITHUB_OUTPUT" 84 | - name: Commit changes 85 | id: auto-commit-action 86 | uses: stefanzweifel/git-auto-commit-action@v7 87 | if: steps.check-changes.outputs.has-changes == 'true' 88 | with: 89 | commit_options: "--allow-empty --no-verify" 90 | tagging_message: ${{ steps.next-version.outputs.NEXT_VERSION }} 91 | skip_dirty_check: true 92 | commit_message: "[Release] Typhoon ${{ steps.next-version.outputs.NEXT_VERSION }}" 93 | - name: Create GitHub Release 94 | uses: softprops/action-gh-release@v2 95 | if: steps.check-changes.outputs.has-changes == 'true' 96 | with: 97 | draft: false 98 | repository: space-code/typhoon 99 | name: ${{ steps.next-version.outputs.NEXT_VERSION }} 100 | tag_name: ${{ steps.next-version.outputs.NEXT_VERSION }} 101 | body: ${{ steps.release-notes.outputs.RELEASE_NOTES }} 102 | target_commitish: ${{ steps.auto-commit-action.outputs.commit_hash }} 103 | 104 | docc: 105 | name: build and deploy docc 106 | runs-on: macos-latest 107 | needs: release 108 | if: ${{ needs.release.outputs.has-changes == 'true' }} 109 | timeout-minutes: 15 110 | steps: 111 | - uses: actions/checkout@v6 112 | with: 113 | ref: ${{ needs.release.outputs.commit-hash }} 114 | fetch-depth: 0 115 | - name: Build DocC 116 | id: build 117 | uses: space-code/build-docc@main 118 | with: 119 | schemes: '["Typhoon"]' 120 | version: ${{ needs.release.outputs.next-version }} 121 | - name: Generate Index Page 122 | uses: space-code/generate-index@v1.0.0 123 | with: 124 | version: ${{ needs.release.outputs.next-version }} 125 | project-name: 'Typhoon' 126 | project-description: 'Typhoon is a modern, lightweight Swift framework that provides elegant and robust retry policies for asynchronous operations.' 127 | modules: | 128 | [ 129 | { 130 | "name": "Typhoon", 131 | "path": "typhoon", 132 | "description": "Core retry mechanisms and policies for asynchronous operations. Includes retry strategies, backoff algorithms, and cancellation support.", 133 | "badge": "Core Module" 134 | } 135 | ] 136 | - name: Deploy 137 | uses: peaceiris/actions-gh-pages@v4 138 | with: 139 | github_token: ${{ secrets.GITHUB_TOKEN }} 140 | publish_dir: ./docs -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "typhoon" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | paths: 9 | - '.swiftlint.yml' 10 | - ".github/workflows/**" 11 | - "Package.swift" 12 | - "Source/**" 13 | - "Tests/**" 14 | 15 | permissions: 16 | contents: read 17 | 18 | concurrency: 19 | group: typhoon-${{ github.head_ref }} 20 | cancel-in-progress: true 21 | 22 | env: 23 | SCHEME_NAME: "Typhoon" 24 | 25 | jobs: 26 | test-apple-platforms: 27 | name: ${{ matrix.name }} 28 | runs-on: ${{ matrix.runsOn }} 29 | env: 30 | DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" 31 | timeout-minutes: 20 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | include: 36 | # macOS 37 | - { platform: macOS, name: "macOS 26, Xcode 26.0, Swift 6.2.0", xcode: "Xcode_26.0.1", runsOn: macOS-26, destination: "platform=macOS" } 38 | - { platform: macOS, name: "macOS 14, Xcode 16.1, Swift 6.0.2", xcode: "Xcode_16.1", runsOn: macOS-14, destination: "platform=macOS" } 39 | - { platform: macOS, name: "macOS 14, Xcode 15.4, Swift 5.10", xcode: "Xcode_15.4", runsOn: macOS-14, destination: "platform=macOS" } 40 | 41 | # iOS 42 | - { platform: iOS, name: "iOS 26.0, Xcode 26.0, Swift 6.2.0", xcode: "Xcode_26.0.1", runsOn: macOS-26, destination: "OS=26.0.1,name=iPhone 17 Pro" } 43 | - { platform: iOS, name: "iOS 18.1", xcode: "Xcode_16.1", runsOn: macOS-14, destination: "OS=18.1,name=iPhone 16 Pro" } 44 | - { platform: iOS, name: "iOS 17.4", xcode: "Xcode_15.3", runsOn: macOS-14, destination: "OS=17.4,name=iPhone 15 Pro" } 45 | 46 | # tvOS 47 | - { platform: tvOS, name: "tvOS 26.0", xcode: "Xcode_26.0.1", runsOn: macOS-26, destination: "OS=26.0,name=Apple TV" } 48 | - { platform: tvOS, name: "tvOS 18.1", xcode: "Xcode_16.1", runsOn: macOS-14, destination: "OS=18.1,name=Apple TV" } 49 | - { platform: tvOS, name: "tvOS 17.4", xcode: "Xcode_15.3", runsOn: macOS-14, destination: "OS=17.4,name=Apple TV" } 50 | 51 | # watchOS 52 | - { platform: watchOS, name: "watchOS 26.0", xcode: "Xcode_26.0.1", runsOn: macOS-26, destination: "OS=26.0,name=Apple Watch Ultra 3 (49mm)" } 53 | - { platform: watchOS, name: "watchOS 11.1", xcode: "Xcode_16.1", runsOn: macOS-14, destination: "OS=11.1,name=Apple Watch Series 10 (46mm)" } 54 | - { platform: watchOS, name: "watchOS 10.5", xcode: "Xcode_15.3", runsOn: macOS-14, destination: "OS=10.5,name=Apple Watch Series 9 (45mm)" } 55 | - { platform: watchOS, name: "watchOS 10.4", xcode: "Xcode_15.3", runsOn: macOS-14, destination: "OS=10.4,name=Apple Watch Series 9 (45mm)" } 56 | steps: 57 | - name: Checkout code 58 | uses: actions/checkout@v6 59 | - name: Run tests - ${{ matrix.name }} 60 | run: | 61 | xcodebuild test \ 62 | -scheme "${{ env.SCHEME_NAME }}" \ 63 | -destination "${{ matrix.destination }}" \ 64 | -enableCodeCoverage YES \ 65 | -resultBundlePath "test_output/${{ matrix.name }}.xcresult" \ 66 | clean || exit 1 67 | - name: Upload test coverage to Codecov 68 | uses: space-code/oss-common-actions/.github/actions/upload_test_coverage_report@main 69 | with: 70 | scheme_name: ${{ env.SCHEME_NAME }} 71 | filename: ${{ matrix.name }} 72 | token: ${{ secrets.CODECOV_TOKEN }} 73 | 74 | spm-build: 75 | name: ${{ matrix.name }} 76 | runs-on: ${{ matrix.runsOn }} 77 | env: 78 | DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" 79 | timeout-minutes: 20 80 | strategy: 81 | fail-fast: false 82 | matrix: 83 | include: 84 | - { name: "macOS 26, SPM 6.2.0", xcode: "Xcode_26.0.1", runsOn: macOS-26 } 85 | - { name: "macOS 15, SPM 6.0.2", xcode: "Xcode_16.0", runsOn: macOS-15 } 86 | - { name: "macOS 14, SPM 6.0.2", xcode: "Xcode_16.1", runsOn: macOS-14 } 87 | - { name: "macOS 14, SPM 5.10.0", xcode: "Xcode_15.3", runsOn: macOS-14 } 88 | steps: 89 | - name: Checkout code 90 | uses: actions/checkout@v6 91 | - name: Build with Swift Package Manager - ${{ matrix.name }} 92 | run: swift build -c release 93 | 94 | test-linux: 95 | name: Linux 96 | runs-on: ubuntu-latest 97 | strategy: 98 | fail-fast: false 99 | matrix: 100 | include: 101 | - image: swift:6.0-focal 102 | - image: swift:6.0-jammy 103 | - image: swift:6.0-rhel-ubi9 104 | - image: swift:6.1-focal 105 | - image: swift:6.1-jammy 106 | - image: swift:6.1-rhel-ubi9 107 | - image: swift:6.2-bookworm 108 | - image: swift:6.2-jammy 109 | - image: swift:6.2-noble 110 | - image: swift:6.2-rhel-ubi9 111 | - image: swiftlang/swift:nightly-focal 112 | - image: swiftlang/swift:nightly-jammy 113 | container: 114 | image: ${{ matrix.image }} 115 | timeout-minutes: 10 116 | steps: 117 | - name: Checkout code 118 | uses: actions/checkout@v6 119 | - name: ${{ matrix.image }} 120 | run: swift build --build-tests -c debug 121 | 122 | test-android: 123 | name: Android 124 | strategy: 125 | fail-fast: false 126 | runs-on: ubuntu-latest 127 | timeout-minutes: 15 128 | steps: 129 | - name: Checkout code 130 | uses: actions/checkout@v6 131 | - name: Build for Android 132 | uses: skiptools/swift-android-action@v2 133 | with: 134 | build-tests: true 135 | run-tests: false 136 | 137 | merge-test-reports: 138 | needs: test-apple-platforms 139 | runs-on: macos-15 140 | steps: 141 | - name: Download artifacts 142 | uses: actions/download-artifact@v7 143 | with: 144 | path: test_output 145 | 146 | - run: xcrun xcresulttool merge test_output/**/*.xcresult --output-path test_output/final/final.xcresult 147 | 148 | - name: Upload merged test results 149 | uses: actions/upload-artifact@v6 150 | with: 151 | name: MergedTestResults 152 | path: test_output/final 153 | retention-days: 30 -------------------------------------------------------------------------------- /Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typhoon 3 | // Copyright © 2023 Space Code. All rights reserved. 4 | // 5 | 6 | @testable import Typhoon 7 | import XCTest 8 | 9 | // MARK: - RetrySequenceTests 10 | 11 | final class RetrySequenceTests: XCTestCase { 12 | // MARK: Tests 13 | 14 | func test_thatRetrySequenceCreatesASequence_whenStrategyIsConstant() { 15 | // given 16 | let sequence = RetrySequence(strategy: .constant(retry: .retry, duration: .nanosecond)) 17 | 18 | // when 19 | let result: [UInt64] = sequence.map { $0 } 20 | 21 | // then 22 | XCTAssertEqual(result, [1, 1, 1, 1, 1, 1, 1, 1]) 23 | } 24 | 25 | func test_thatRetrySequenceCreatesASequence_whenStrategyIsExponential() { 26 | // given 27 | let sequence = RetrySequence(strategy: .exponential(retry: .retry, duration: .nanosecond)) 28 | 29 | // when 30 | let result: [UInt64] = sequence.map { $0 } 31 | 32 | // then 33 | XCTAssertEqual(result, [1, 2, 4, 8, 16, 32, 64, 128]) 34 | } 35 | 36 | func test_thatRetrySequenceCreatesASequence_whenStrategyIsExponentialWithJitter() { 37 | // given 38 | let durationSeconds = 1.0 39 | let multiplier = 2.0 40 | let jitterFactor = 0.1 41 | 42 | let sequence = RetrySequence( 43 | strategy: .exponentialWithJitter( 44 | retry: 5, 45 | jitterFactor: jitterFactor, 46 | maxInterval: nil, 47 | multiplier: multiplier, 48 | duration: .seconds(Int(durationSeconds)) 49 | ) 50 | ) 51 | 52 | // when 53 | let result: [UInt64] = sequence.map { $0 } 54 | 55 | // then 56 | XCTAssertEqual(result.count, 5) 57 | 58 | for (i, valueNanos) in result.enumerated() { 59 | let seconds = toSeconds(valueNanos) 60 | 61 | let expectedBase = durationSeconds * pow(multiplier, Double(i)) 62 | 63 | let lowerBound = expectedBase * (1.0 - jitterFactor) 64 | let upperBound = expectedBase * (1.0 + jitterFactor) 65 | 66 | XCTAssertTrue( 67 | seconds >= lowerBound && seconds <= upperBound, 68 | "Attempt \(i): \(seconds)s should be between \(lowerBound)s and \(upperBound)s" 69 | ) 70 | } 71 | } 72 | 73 | func test_thatRetrySequenceRespectsMaxInterval_whenStrategyIsExponentialWithJitter() { 74 | // given 75 | let maxIntervalDuration: DispatchTimeInterval = .seconds(10) 76 | let maxIntervalNanos: UInt64 = 10 * 1_000_000_000 77 | 78 | let sequence = RetrySequence( 79 | strategy: .exponentialWithJitter( 80 | retry: 10, 81 | jitterFactor: 0.1, 82 | maxInterval: maxIntervalDuration, 83 | multiplier: 2.0, 84 | duration: .seconds(1) 85 | ) 86 | ) 87 | 88 | // when 89 | let result: [UInt64] = sequence.map { $0 } 90 | 91 | // then 92 | XCTAssertEqual(result.count, 10) 93 | 94 | for (i, val) in result.enumerated() { 95 | XCTAssertLessThanOrEqual(val, maxIntervalNanos, "Attempt \(i) exceeded maxInterval") 96 | 97 | let expectedBaseSeconds = 1.0 * pow(2.0, Double(i)) 98 | 99 | if expectedBaseSeconds * (1.0 - 0.1) > 10.0 { 100 | XCTAssertEqual(val, maxIntervalNanos, "Attempt \(i) should be capped at maxInterval") 101 | } 102 | } 103 | } 104 | 105 | func test_thatRetrySequenceAppliesJitter_whenStrategyIsExponentialWithJitter() { 106 | // given 107 | let strategy = RetryPolicyStrategy.exponentialWithJitter( 108 | retry: 30, 109 | jitterFactor: 0.5, 110 | maxInterval: nil, 111 | multiplier: 2.0, 112 | duration: .milliseconds(10) 113 | ) 114 | 115 | let sequence1 = RetrySequence(strategy: strategy) 116 | let sequence2 = RetrySequence(strategy: strategy) 117 | 118 | // when 119 | let result1 = sequence1.map { $0 } 120 | let result2 = sequence2.map { $0 } 121 | 122 | // then 123 | XCTAssertEqual(result1.count, 30) 124 | 125 | XCTAssertNotEqual(result1, result2, "Two sequences with jitter should produce different values") 126 | 127 | for (i, val) in result1.enumerated() { 128 | let seconds = toSeconds(val) 129 | 130 | let base = 0.01 * pow(2.0, Double(i)) 131 | 132 | let lower = base * 0.5 133 | let upper = base * 1.5 134 | 135 | XCTAssertTrue( 136 | seconds >= lower && seconds <= upper, 137 | "Attempt \(i): Value \(seconds) is out of bounds [\(lower), \(upper)]" 138 | ) 139 | } 140 | } 141 | 142 | func test_thatRetrySequenceWorksWithoutMaxInterval_whenStrategyIsExponentialWithJitter() { 143 | // given 144 | let sequence = RetrySequence( 145 | strategy: .exponentialWithJitter( 146 | retry: 5, 147 | jitterFactor: 0.1, 148 | maxInterval: nil, 149 | multiplier: 2.0, 150 | duration: .seconds(1) 151 | ) 152 | ) 153 | 154 | // when 155 | let result: [UInt64] = sequence.map { $0 } 156 | 157 | // then 158 | XCTAssertEqual(result.count, 5) 159 | 160 | for i in 1 ..< result.count { 161 | XCTAssertGreaterThan( 162 | result[i], 163 | result[i - 1], 164 | "Each delay should be greater than previous (exponential growth)" 165 | ) 166 | } 167 | 168 | XCTAssertGreaterThan(result[4], result[0] * 10) 169 | } 170 | 171 | func test_thatRetrySequenceDoesNotLimitASequence_whenStrategyIsExponentialWithJitterAndMaxIntervalIsNil() { 172 | // given 173 | let sequence = RetrySequence( 174 | strategy: .exponentialWithJitter( 175 | retry: .retry, 176 | jitterFactor: .jitterFactor, 177 | maxInterval: nil, 178 | duration: .nanosecond 179 | ) 180 | ) 181 | 182 | // when 183 | let result: [UInt64] = sequence.map { $0 } 184 | 185 | // then 186 | XCTAssertEqual(result.count, 8) 187 | XCTAssertEqual(result[0], 1, accuracy: 1) 188 | XCTAssertEqual(result[1], 2, accuracy: 1) 189 | XCTAssertEqual(result[2], 4, accuracy: 1) 190 | XCTAssertEqual(result[3], 8, accuracy: 1) 191 | XCTAssertEqual(result[4], 16, accuracy: 2) 192 | XCTAssertEqual(result[5], 32, accuracy: 4) 193 | XCTAssertEqual(result[6], 64, accuracy: 8) 194 | XCTAssertEqual(result[7], 128, accuracy: 13) 195 | } 196 | 197 | // MARK: Helpers 198 | 199 | private func toSeconds(_ nanos: UInt64) -> Double { 200 | Double(nanos) / 1_000_000_000 201 | } 202 | } 203 | 204 | // MARK: - Constant 205 | 206 | private extension Int { 207 | static let retry: Int = 8 208 | } 209 | 210 | private extension UInt64 { 211 | static let maxInterval: UInt64 = 60 212 | } 213 | 214 | private extension Double { 215 | static let multiplier = 2.0 216 | static let jitterFactor = 0.1 217 | } 218 | 219 | private extension DispatchTimeInterval { 220 | static let nanosecond = DispatchTimeInterval.nanoseconds(1) 221 | } 222 | -------------------------------------------------------------------------------- /Tests/TyphoonTests/UnitTests/RetryPolicyServiceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typhoon 3 | // Copyright © 2023 Space Code. All rights reserved. 4 | // 5 | 6 | import Typhoon 7 | import XCTest 8 | 9 | // MARK: - RetryPolicyServiceTests 10 | 11 | final class RetryPolicyServiceTests: XCTestCase { 12 | // MARK: Properties 13 | 14 | private var sut: IRetryPolicyService! 15 | 16 | // MARK: Lifecycle 17 | 18 | override func setUp() { 19 | super.setUp() 20 | sut = RetryPolicyService(strategy: .constant(retry: .defaultRetryCount, duration: .seconds(0))) 21 | } 22 | 23 | override func tearDown() { 24 | sut = nil 25 | super.tearDown() 26 | } 27 | 28 | // MARK: Tests - Error Handling 29 | 30 | func test_thatRetryThrowsRetryLimitExceededError_whenAllRetriesFail() async throws { 31 | // given 32 | let expectedError = RetryPolicyError.retryLimitExceeded 33 | 34 | // when 35 | var receivedError: Error? 36 | do { 37 | _ = try await sut.retry { throw URLError(.unknown) } 38 | } catch { 39 | receivedError = error 40 | } 41 | 42 | // then 43 | XCTAssertEqual(receivedError as? NSError, expectedError as NSError) 44 | } 45 | 46 | func test_thatRetryThrowsOriginalError_whenOnFailureReturnsFalse() async throws { 47 | // given 48 | let originalError = URLError(.timedOut) 49 | 50 | // when 51 | var receivedError: Error? 52 | do { 53 | _ = try await sut.retry( 54 | strategy: .constant(retry: .defaultRetryCount, duration: .nanoseconds(1)), 55 | onFailure: { _ in false } 56 | ) { 57 | throw originalError 58 | } 59 | } catch { 60 | receivedError = error 61 | } 62 | 63 | // then 64 | XCTAssertEqual(receivedError as? URLError, originalError) 65 | } 66 | 67 | // MARK: Tests - Success Cases 68 | 69 | func test_thatRetryReturnsValue_whenOperationSucceedsImmediately() async throws { 70 | // given 71 | let expectedValue = 42 72 | 73 | // when 74 | let result = try await sut.retry { 75 | expectedValue 76 | } 77 | 78 | // then 79 | XCTAssertEqual(result, expectedValue) 80 | } 81 | 82 | func test_thatRetryReturnsValue_whenOperationSucceedsAfterRetries() async throws { 83 | // given 84 | let counter = Counter() 85 | let expectedValue = 100 86 | 87 | // when 88 | let result = try await sut.retry( 89 | strategy: .constant(retry: .defaultRetryCount, duration: .nanoseconds(1)) 90 | ) { 91 | let currentCount = await counter.increment() 92 | 93 | if currentCount >= .defaultRetryCount { 94 | return expectedValue 95 | } 96 | throw URLError(.unknown) 97 | } 98 | 99 | // then 100 | XCTAssertEqual(result, expectedValue) 101 | let finalCount = await counter.getValue() 102 | XCTAssertEqual(finalCount, .defaultRetryCount) 103 | } 104 | 105 | // MARK: Tests - Retry Count 106 | 107 | func test_thatRetryAttemptsCorrectNumberOfTimes_whenAllRetriesFail() async throws { 108 | // given 109 | let counter = Counter() 110 | 111 | // when 112 | do { 113 | _ = try await sut.retry( 114 | strategy: .constant(retry: .defaultRetryCount, duration: .nanoseconds(1)) 115 | ) { 116 | _ = await counter.increment() 117 | throw URLError(.unknown) 118 | } 119 | } catch {} 120 | 121 | // then 122 | let attemptCount = await counter.getValue() 123 | XCTAssertEqual(attemptCount, .defaultRetryCount + 1) 124 | } 125 | 126 | func test_thatRetryStopsImmediately_whenOnFailureReturnsFalse() async throws { 127 | // given 128 | let counter = Counter() 129 | 130 | // when 131 | do { 132 | _ = try await sut.retry( 133 | strategy: .constant(retry: .defaultRetryCount, duration: .nanoseconds(1)), 134 | onFailure: { _ in false } 135 | ) { 136 | _ = await counter.increment() 137 | throw URLError(.unknown) 138 | } 139 | } catch {} 140 | 141 | // then 142 | let attemptCount = await counter.getValue() 143 | XCTAssertEqual(attemptCount, 1) 144 | } 145 | 146 | // MARK: Tests - Failure Callback 147 | 148 | func test_thatRetryInvokesOnFailureCallback_whenErrorOccurs() async { 149 | // given 150 | let errorContainer = ErrorContainer() 151 | let expectedError = URLError(.notConnectedToInternet) 152 | 153 | // when 154 | do { 155 | _ = try await sut.retry( 156 | strategy: .constant(retry: .defaultRetryCount, duration: .nanoseconds(1)), 157 | onFailure: { error in 158 | await errorContainer.setError(error as NSError) 159 | return false 160 | } 161 | ) { 162 | throw expectedError 163 | } 164 | } catch {} 165 | 166 | // then 167 | let capturedError = await errorContainer.getError() 168 | XCTAssertEqual(capturedError as? URLError, expectedError) 169 | } 170 | 171 | func test_thatRetryInvokesOnFailureMultipleTimes_whenMultipleRetriesFail() async { 172 | // given 173 | let counter = Counter() 174 | let expectedCallCount = 3 175 | 176 | // when 177 | do { 178 | _ = try await sut.retry( 179 | strategy: .constant(retry: expectedCallCount, duration: .nanoseconds(1)), 180 | onFailure: { _ in 181 | true 182 | } 183 | ) { 184 | _ = await counter.increment() 185 | throw URLError(.unknown) 186 | } 187 | } catch {} 188 | 189 | // then 190 | let callCount = await counter.getValue() 191 | XCTAssertEqual(callCount, expectedCallCount + 1) 192 | } 193 | 194 | // MARK: Tests - Edge Cases 195 | 196 | func test_thatRetryReturnsValue_whenRetryCountIsZero() async throws { 197 | // given 198 | let expectedValue = 7 199 | let zeroRetryStrategy = RetryPolicyService( 200 | strategy: .constant(retry: 0, duration: .nanoseconds(1)) 201 | ) 202 | 203 | // when 204 | let result = try await zeroRetryStrategy.retry { 205 | expectedValue 206 | } 207 | 208 | // then 209 | XCTAssertEqual(result, expectedValue) 210 | } 211 | 212 | func test_thatRetryThrowsError_whenRetryCountIsZeroAndOperationFails() async throws { 213 | // given 214 | let zeroRetryStrategy = RetryPolicyService( 215 | strategy: .constant(retry: 0, duration: .nanoseconds(1)) 216 | ) 217 | 218 | // when 219 | var receivedError: Error? 220 | do { 221 | _ = try await zeroRetryStrategy.retry { 222 | throw URLError(.badURL) 223 | } 224 | } catch { 225 | receivedError = error 226 | } 227 | 228 | // then 229 | XCTAssertNotNil(receivedError) 230 | } 231 | 232 | func test_thatRetryHandlesDifferentErrorTypes_whenMultipleErrorsOccur() async { 233 | // given 234 | let errorContainer = ErrorContainer() 235 | let counter = Counter() 236 | let errors: [Error] = [ 237 | URLError(.badURL), 238 | URLError(.timedOut), 239 | URLError(.cannotFindHost), 240 | ] 241 | 242 | // when 243 | do { 244 | _ = try await sut.retry( 245 | strategy: .constant(retry: errors.count, duration: .nanoseconds(1)), 246 | onFailure: { error in 247 | await errorContainer.setError(error as NSError) 248 | return true 249 | } 250 | ) { 251 | let index = await counter.increment() - 1 252 | throw errors[min(index, errors.count - 1)] 253 | } 254 | } catch {} 255 | 256 | // then 257 | let lastError = await errorContainer.getError() 258 | XCTAssertNotNil(lastError) 259 | } 260 | } 261 | 262 | // MARK: - Counter 263 | 264 | private actor Counter { 265 | // MARK: Properties 266 | 267 | private var value: Int = 0 268 | 269 | // MARK: Internal 270 | 271 | func increment() -> Int { 272 | value += 1 273 | return value 274 | } 275 | 276 | func getValue() -> Int { 277 | value 278 | } 279 | } 280 | 281 | // MARK: - ErrorContainer 282 | 283 | private actor ErrorContainer { 284 | // MARK: Properties 285 | 286 | private var error: NSError? 287 | 288 | // MARK: Internal 289 | 290 | func setError(_ newError: NSError) { 291 | error = newError 292 | } 293 | 294 | func getError() -> NSError? { 295 | error 296 | } 297 | } 298 | 299 | // MARK: - Constants 300 | 301 | private extension Int { 302 | static let defaultRetryCount = 5 303 | } 304 | -------------------------------------------------------------------------------- /Sources/Typhoon/Classes/RetrySequence/Iterator/RetryIterator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typhoon 3 | // Copyright © 2023 Space Code. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | // MARK: - RetryIterator 9 | 10 | /// An iterator that generates retry delays according to a retry policy strategy. 11 | /// 12 | /// `RetryIterator` conforms to `IteratorProtocol` and produces a sequence of delay 13 | /// values (in nanoseconds) that can be used to schedule retry attempts for 14 | /// asynchronous operations such as network requests or background tasks. 15 | /// 16 | /// Each call to `next()` returns the delay for the current retry attempt and then 17 | /// advances the internal retry counter. When the maximum number of retries defined 18 | /// by the strategy is reached, the iterator stops producing values. 19 | struct RetryIterator: IteratorProtocol { 20 | // MARK: Properties 21 | 22 | /// The current retry attempt index. 23 | /// 24 | /// Starts from `0` and is incremented after each successful call to `next()`. 25 | /// This value is used when calculating exponential backoff delays. 26 | private var retries: UInt = 0 27 | 28 | /// The retry policy strategy that defines: 29 | /// - The maximum number of retry attempts. 30 | /// - The algorithm used to calculate delays between retries 31 | /// (constant, exponential, or exponential with jitter). 32 | private let strategy: RetryPolicyStrategy 33 | 34 | // MARK: Initialization 35 | 36 | /// Creates a new `RetryIterator` with the specified retry policy strategy. 37 | /// 38 | /// - Parameter strategy: A `RetryPolicyStrategy` describing how retry delays 39 | /// should be calculated and how many retries are allowed. 40 | init(strategy: RetryPolicyStrategy) { 41 | self.strategy = strategy 42 | } 43 | 44 | // MARK: IteratorProtocol 45 | 46 | /// Returns the delay for the next retry attempt. 47 | /// 48 | /// The delay is calculated according to the current retry policy strategy 49 | /// and expressed in **nanoseconds**, making it suitable for use with 50 | /// `DispatchQueue`, `Task.sleep`, or other low-level scheduling APIs. 51 | /// 52 | /// After the delay is calculated, the internal retry counter is incremented. 53 | /// When the maximum number of retries is exceeded, this method returns `nil`, 54 | /// signaling the end of the iteration. 55 | /// 56 | /// - Returns: The delay in nanoseconds for the current retry attempt, 57 | /// or `nil` if no more retries are allowed. 58 | mutating func next() -> UInt64? { 59 | guard isValid() else { return nil } 60 | 61 | defer { retries += 1 } 62 | 63 | return delay() 64 | } 65 | 66 | // MARK: Private 67 | 68 | /// Determines whether another retry attempt is allowed. 69 | /// 70 | /// This method compares the current retry count with the maximum 71 | /// number of retries defined in the retry strategy. 72 | /// 73 | /// - Returns: `true` if another retry attempt is allowed; 74 | /// `false` otherwise. 75 | private func isValid() -> Bool { 76 | retries < strategy.retries 77 | } 78 | 79 | /// Calculates the delay for the current retry attempt 80 | /// based on the selected retry strategy. 81 | /// 82 | /// - Returns: The computed delay in nanoseconds, or `0` 83 | /// if the duration cannot be converted to seconds. 84 | private func delay() -> UInt64? { 85 | switch strategy { 86 | case let .constant(_, duration): 87 | convertToNanoseconds(duration) 88 | 89 | case let .exponential(_, multiplier, duration): 90 | calculateExponentialDelay( 91 | duration: duration, 92 | multiplier: multiplier, 93 | retries: retries 94 | ) 95 | 96 | case let .exponentialWithJitter(_, jitterFactor, maxInterval, multiplier, duration): 97 | calculateExponentialDelayWithJitter( 98 | duration: duration, 99 | multiplier: multiplier, 100 | retries: retries, 101 | jitterFactor: jitterFactor, 102 | maxInterval: maxInterval 103 | ) 104 | } 105 | } 106 | 107 | // MARK: - Helper Methods 108 | 109 | /// Converts a `DispatchTimeInterval` to nanoseconds. 110 | /// 111 | /// - Parameter duration: The time interval to convert. 112 | /// - Returns: The equivalent duration in nanoseconds, or `0` 113 | /// if the interval cannot be represented as seconds. 114 | private func convertToNanoseconds(_ duration: DispatchTimeInterval) -> UInt64? { 115 | guard let seconds = duration.double else { return .zero } 116 | return safeConvertToUInt64(seconds * .nanosec) 117 | } 118 | 119 | /// Calculates an exponential backoff delay without jitter. 120 | /// 121 | /// The delay is calculated as: 122 | /// `baseDelay * multiplier ^ retries` 123 | /// 124 | /// - Parameters: 125 | /// - duration: The base delay value. 126 | /// - multiplier: The exponential growth multiplier. 127 | /// - retries: The current retry attempt index. 128 | /// - Returns: The calculated delay in nanoseconds. 129 | private func calculateExponentialDelay( 130 | duration: DispatchTimeInterval, 131 | multiplier: Double, 132 | retries: UInt 133 | ) -> UInt64? { 134 | guard let seconds = duration.double else { return .zero } 135 | 136 | let baseNanos = seconds * .nanosec 137 | let value = baseNanos * pow(multiplier, Double(retries)) 138 | 139 | return safeConvertToUInt64(value) 140 | } 141 | 142 | /// Calculates an exponential backoff delay with jitter and an optional maximum interval. 143 | /// 144 | /// This method: 145 | /// 1. Calculates the exponential backoff delay. 146 | /// 2. Applies a random jitter to spread retry attempts over time. 147 | /// 3. Caps the result at the provided maximum interval, if any. 148 | /// 149 | /// - Parameters: 150 | /// - duration: The base delay value. 151 | /// - multiplier: The exponential growth multiplier. 152 | /// - retries: The current retry attempt index. 153 | /// - jitterFactor: The percentage of randomness applied to the delay. 154 | /// - maxInterval: An optional upper bound for the delay. 155 | /// - Returns: The final delay in nanoseconds. 156 | private func calculateExponentialDelayWithJitter( 157 | duration: DispatchTimeInterval, 158 | multiplier: Double, 159 | retries: UInt, 160 | jitterFactor: Double, 161 | maxInterval: DispatchTimeInterval? 162 | ) -> UInt64? { 163 | guard let seconds = duration.double else { return .zero } 164 | 165 | let maxDelayNanos = calculateMaxDelay(maxInterval) 166 | let baseNanos = seconds * .nanosec 167 | let exponentialBackoffNanos = baseNanos * pow(multiplier, Double(retries)) 168 | 169 | guard exponentialBackoffNanos < maxDelayNanos, 170 | exponentialBackoffNanos < Double(UInt64.max) 171 | else { 172 | return safeConvertToUInt64(maxDelayNanos) 173 | } 174 | 175 | let delayWithJitter = applyJitter( 176 | to: exponentialBackoffNanos, 177 | factor: jitterFactor, 178 | maxDelay: maxDelayNanos 179 | ) 180 | 181 | return safeConvertToUInt64(min(delayWithJitter, maxDelayNanos)) 182 | } 183 | 184 | /// Calculates the maximum allowed delay in nanoseconds. 185 | /// 186 | /// - Parameter maxInterval: An optional maximum delay value. 187 | /// - Returns: The maximum delay in nanoseconds, clamped to `UInt64.max`. 188 | private func calculateMaxDelay(_ maxInterval: DispatchTimeInterval?) -> Double { 189 | guard let maxSeconds = maxInterval?.double else { 190 | return Double(UInt64.max) 191 | } 192 | 193 | let maxNanos = maxSeconds * .nanosec 194 | return min(maxNanos, Double(UInt64.max)) 195 | } 196 | 197 | /// Applies random jitter to a delay value. 198 | /// 199 | /// Jitter helps prevent synchronized retries (the "thundering herd" problem) 200 | /// by randomizing retry timings within a defined range. 201 | /// 202 | /// - Parameters: 203 | /// - value: The base delay value in nanoseconds. 204 | /// - factor: The jitter factor defining the randomization range. 205 | /// - maxDelay: The maximum allowed delay. 206 | /// - Returns: A jittered delay value clamped to valid bounds. 207 | private func applyJitter( 208 | to value: Double, 209 | factor: Double, 210 | maxDelay: Double 211 | ) -> Double { 212 | let jitterRange = value * factor 213 | let minValue = value - jitterRange 214 | let maxValue = min(value + jitterRange, maxDelay) 215 | 216 | guard maxValue < Double(UInt64.max) else { 217 | return maxDelay 218 | } 219 | 220 | let randomized = Double.random(in: minValue ... maxValue) 221 | return max(0, randomized) 222 | } 223 | 224 | private func safeConvertToUInt64(_ value: Double) -> UInt64 { 225 | if value >= Double(UInt64.max) { 226 | return UInt64.max 227 | } 228 | if value <= 0 { 229 | return .zero 230 | } 231 | return UInt64(value) 232 | } 233 | } 234 | 235 | // MARK: - Constants 236 | 237 | private extension Double { 238 | static let nanosec: Double = 1e+9 239 | } 240 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 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](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | #### 1.x Releases 9 | - `1.4.x` Releases - [1.4.0](#140) 10 | - `1.3.x` Releases - [1.3.0](#130) 11 | - `1.2.x` Releases - [1.2.1](#121) | [1.2.0](#120) 12 | - `1.1.x` Releases - [1.1.1](#111) | [1.1.0](#110) 13 | - `1.0.x` Releases - [1.0.0](#100) 14 | 15 | --- 16 | 17 | 18 | ## [1.4.0](https://github.com/space-code/typhoon/releases/tag/1.4.0) 19 | 20 | Released on 2025-12-17. All issues associated with this milestone can be found using this [filter](https://github.com/space-code/typhoon/milestones?state=closed&q=1.4.0). 21 | 22 | ### Bug Fixes 23 | - Safely handle UInt64 overflow and standardize max interval unit 24 | - Fixed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#43](https://github.com/space-code/typhoon/pull/43). 25 | 26 | ### Documentation 27 | - Update README.md 28 | - Documented by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#48](https://github.com/space-code/typhoon/pull/48). 29 | - Add DocC documentation 30 | - Documented by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#44](https://github.com/space-code/typhoon/pull/44). 31 | 32 | ### Features 33 | - Add release workflow for GitHub Actions 34 | - Implemented by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#38](https://github.com/space-code/typhoon/pull/38). 35 | - Switch from Makefile to Mise 36 | - Implemented by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#27](https://github.com/space-code/typhoon/pull/27). 37 | 38 | ### Miscellaneous Tasks 39 | - Fix typos 40 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#52](https://github.com/space-code/typhoon/pull/52). 41 | - Automate code formatting and linting with github actions 42 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#47](https://github.com/space-code/typhoon/pull/47). 43 | - Add SPM build checks for Android and Linux 44 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#46](https://github.com/space-code/typhoon/pull/46). 45 | - Remove the Swiftlint step 46 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#45](https://github.com/space-code/typhoon/pull/45). 47 | - Add codeowners 48 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#41](https://github.com/space-code/typhoon/pull/41). 49 | - Add `.spi.yml` 50 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#39](https://github.com/space-code/typhoon/pull/39). 51 | - Add GitHub issue and PR templates 52 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#37](https://github.com/space-code/typhoon/pull/37). 53 | - Update the danger action 54 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#36](https://github.com/space-code/typhoon/pull/36). 55 | - Add conventional-pr.yml for PR validation 56 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#28](https://github.com/space-code/typhoon/pull/28). 57 | - Update config 58 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#34](https://github.com/space-code/typhoon/pull/34). 59 | 60 | ### Refactor 61 | - Unify retry execution flow 62 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#49](https://github.com/space-code/typhoon/pull/49). 63 | 64 | ### Uncategorized Changes 65 | - Add renovate.json 66 | - Contributed by [@renovate[bot]](https://github.com/renovate[bot]) in Pull Request [#29](https://github.com/space-code/typhoon/pull/29). 67 | 68 | ### New Contributors 69 | * @renovate[bot] made their first contribution in 70 | 71 | ## [1.3.0](https://github.com/space-code/typhoon/releases/tag/1.3.0) 72 | 73 | Released on 2025-11-16. All issues associated with this milestone can be found using this [filter](https://github.com/space-code/typhoon/milestones?state=closed&q=1.3.0). 74 | 75 | ### Uncategorized Changes 76 | - Release `1.3.0` 77 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#24](https://github.com/space-code/typhoon/pull/24). 78 | - Update `CHANGELOG.md` 79 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#25](https://github.com/space-code/typhoon/pull/25). 80 | - Bump actions/checkout from 2 to 5 81 | - Contributed by [@dependabot[bot]](https://github.com/dependabot[bot]) in Pull Request [#23](https://github.com/space-code/typhoon/pull/23). 82 | - Add `dependabot.yml` 83 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#22](https://github.com/space-code/typhoon/pull/22). 84 | - Add support for Swift 6.2 85 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#21](https://github.com/space-code/typhoon/pull/21). 86 | 87 | ### New Contributors 88 | * @dependabot[bot] made their first contribution in [#23](https://github.com/space-code/typhoon/pull/23) 89 | 90 | ## [1.2.1](https://github.com/space-code/typhoon/releases/tag/1.2.1) 91 | 92 | Released on 2024-12-24. All issues associated with this milestone can be found using this [filter](https://github.com/space-code/typhoon/milestones?state=closed&q=1.2.1). 93 | 94 | ### Uncategorized Changes 95 | - Release `1.2.1` 96 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#20](https://github.com/space-code/typhoon/pull/20). 97 | - Update `CHANGELOG.md` 98 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#19](https://github.com/space-code/typhoon/pull/19). 99 | - Mark the closures as `@Sendable` 100 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#18](https://github.com/space-code/typhoon/pull/18). 101 | 102 | ## [1.2.0](https://github.com/space-code/typhoon/releases/tag/1.2.0) 103 | 104 | Released on 2024-12-23. All issues associated with this milestone can be found using this [filter](https://github.com/space-code/typhoon/milestones?state=closed&q=1.2.0). 105 | 106 | ### Uncategorized Changes 107 | - Release `1.2.0` 108 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#17](https://github.com/space-code/typhoon/pull/17). 109 | - Update `CHANGELOG.md` 110 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#16](https://github.com/space-code/typhoon/pull/16). 111 | - Increase the `Swift` version to 6.0 112 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#15](https://github.com/space-code/typhoon/pull/15). 113 | 114 | ## [1.1.1](https://github.com/space-code/typhoon/releases/tag/1.1.1) 115 | 116 | Released on 2024-05-11. All issues associated with this milestone can be found using this [filter](https://github.com/space-code/typhoon/milestones?state=closed&q=1.1.1). 117 | 118 | ### Uncategorized Changes 119 | - Update `CHANGELOG.md` 120 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#14](https://github.com/space-code/typhoon/pull/14). 121 | - Update `CHANGELOG.md` 122 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#13](https://github.com/space-code/typhoon/pull/13). 123 | - Release `1.1.1` 124 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#12](https://github.com/space-code/typhoon/pull/12). 125 | - Update `ci.yml` 126 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#11](https://github.com/space-code/typhoon/pull/11). 127 | - Add files to comply with community standards 128 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#9](https://github.com/space-code/typhoon/pull/9). 129 | - Update workflow 130 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#10](https://github.com/space-code/typhoon/pull/10). 131 | 132 | ## [1.1.0](https://github.com/space-code/typhoon/releases/tag/1.1.0) 133 | 134 | Released on 2023-12-08. All issues associated with this milestone can be found using this [filter](https://github.com/space-code/typhoon/milestones?state=closed&q=1.1.0). 135 | 136 | ### Uncategorized Changes 137 | - Release `1.1.0` 138 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#8](https://github.com/space-code/typhoon/pull/8). 139 | - Update `CHANGELOG.md` 140 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#7](https://github.com/space-code/typhoon/pull/7). 141 | - Implement `RetryPolicyStrategyTests` 142 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#6](https://github.com/space-code/typhoon/pull/6). 143 | - Implement error handling in `RetryPolicyService` 144 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#5](https://github.com/space-code/typhoon/pull/5). 145 | - Implement exponential backoff with jitter 146 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#4](https://github.com/space-code/typhoon/pull/4). 147 | - Lowered the minimum OS version 148 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#2](https://github.com/space-code/typhoon/pull/2). 149 | 150 | ## [1.0.0](https://github.com/space-code/typhoon/releases/tag/1.0.0) 151 | 152 | Released on 2023-11-12. All issues associated with this milestone can be found using this [filter](https://github.com/space-code/typhoon/milestones?state=closed&q=1.0.0). 153 | 154 | ### Uncategorized Changes 155 | - Lowered the minimum OS version 156 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#3](https://github.com/space-code/typhoon/pull/3). 157 | - Release `1.0.0` 158 | - Contributed by [@ns-vasilev](https://github.com/ns-vasilev) in Pull Request [#1](https://github.com/space-code/typhoon/pull/1). 159 | 160 | [1.4.0]: https://github.com/space-code/typhoon/compare/1.3.0..1.4.0 161 | [1.3.0]: https://github.com/space-code/typhoon/compare/1.2.1..1.3.0 162 | [1.2.1]: https://github.com/space-code/typhoon/compare/1.2.0..1.2.1 163 | [1.2.0]: https://github.com/space-code/typhoon/compare/1.1.1..1.2.0 164 | [1.1.1]: https://github.com/space-code/typhoon/compare/1.1.0..1.1.1 165 | [1.1.0]: https://github.com/space-code/typhoon/compare/1.0.0..1.1.0 166 | 167 | -------------------------------------------------------------------------------- /Sources/Typhoon/Typhoon.docc/Articles/best-practices.md: -------------------------------------------------------------------------------- 1 | # Best Practices 2 | 3 | Learn the recommended patterns and practices for using Typhoon effectively. 4 | 5 | ## Overview 6 | 7 | This guide covers best practices, common pitfalls, and recommended patterns for implementing retry logic in production applications. 8 | 9 | ## Strategy Selection 10 | 11 | ### Choose Based on Use Case 12 | 13 | Different scenarios require different retry strategies: 14 | 15 | ```swift 16 | // ✅ Fast local operations (file I/O, cache) 17 | let localRetry = RetryPolicyService( 18 | strategy: .constant(retry: 3, duration: .milliseconds(100)) 19 | ) 20 | 21 | // ✅ Standard API calls 22 | let apiRetry = RetryPolicyService( 23 | strategy: .exponential(retry: 4, multiplier: 2.0, duration: .seconds(1)) 24 | ) 25 | 26 | // ✅ High-traffic services 27 | let highTrafficRetry = RetryPolicyService( 28 | strategy: .exponentialWithJitter( 29 | retry: 5, 30 | jitterFactor: 0.2, 31 | maxInterval: 60, 32 | multiplier: 2.0, 33 | duration: .seconds(1) 34 | ) 35 | ) 36 | 37 | // ❌ Wrong - Too many retries for quick operation 38 | let badRetry = RetryPolicyService( 39 | strategy: .exponential(retry: 20, duration: .seconds(10)) 40 | ) 41 | ``` 42 | 43 | ### Recommended Configurations 44 | 45 | | Operation Type | Strategy | Retry Count | Base Duration | Notes | 46 | |---------------|----------|-------------|---------------|-------| 47 | | Cache access | Constant | 2-3 | 50-100ms | Fast recovery | 48 | | Database query | Constant | 3-5 | 100-500ms | Predictable delays | 49 | | REST API | Exponential | 3-4 | 500ms-1s | Standard backoff | 50 | | GraphQL API | Exponential | 3-5 | 1-2s | Handle complex queries | 51 | | File upload | Exponential + Jitter | 5-7 | 2-5s | Large operations | 52 | | Critical payment | Exponential + Jitter | 5-10 | 1-2s | Maximum reliability | 53 | | Rate-limited API | Constant | 3-5 | Based on rate limit | Respect limits | 54 | 55 | ## Service Architecture 56 | 57 | ### Reuse Service Instances 58 | 59 | Create retry services at the appropriate scope: 60 | 61 | ```swift 62 | // ✅ Good - Singleton or service property 63 | class APIClient { 64 | static let shared = APIClient() 65 | 66 | private let retryService = RetryPolicyService( 67 | strategy: .exponential(retry: 3, duration: .seconds(1)) 68 | ) 69 | 70 | func fetchUser(id: String) async throws -> User { 71 | try await retryService.retry { 72 | try await performFetch(id: id) 73 | } 74 | } 75 | } 76 | 77 | // ✅ Good - Dependency injection 78 | class DataRepository { 79 | private let retryService: RetryPolicyService 80 | 81 | init(retryService: RetryPolicyService = .default) { 82 | self.retryService = retryService 83 | } 84 | } 85 | 86 | // ❌ Bad - Creating new instances repeatedly 87 | func fetchData() async throws -> Data { 88 | let service = RetryPolicyService(...) // Don't do this! 89 | return try await service.retry { ... } 90 | } 91 | ``` 92 | 93 | ### Organize by Layer 94 | 95 | Structure retry services by architectural layer: 96 | 97 | ```swift 98 | // Network Layer 99 | class NetworkRetryService { 100 | static let standard = RetryPolicyService( 101 | strategy: .exponential(retry: 3, duration: .milliseconds(500)) 102 | ) 103 | 104 | static let critical = RetryPolicyService( 105 | strategy: .exponentialWithJitter( 106 | retry: 5, 107 | jitterFactor: 0.2, 108 | maxInterval: 60, 109 | multiplier: 2.0, 110 | duration: .seconds(1) 111 | ) 112 | ) 113 | } 114 | 115 | // Data Layer 116 | class DataRetryService { 117 | static let cache = RetryPolicyService( 118 | strategy: .constant(retry: 2, duration: .milliseconds(50)) 119 | ) 120 | 121 | static let database = RetryPolicyService( 122 | strategy: .constant(retry: 3, duration: .milliseconds(200)) 123 | ) 124 | } 125 | 126 | // Usage 127 | class UserRepository { 128 | func fetchUser(id: String) async throws -> User { 129 | try await NetworkRetryService.standard.retry { 130 | try await api.getUser(id: id) 131 | } 132 | } 133 | 134 | func cacheUser(_ user: User) async throws { 135 | try await DataRetryService.cache.retry { 136 | try cache.save(user) 137 | } 138 | } 139 | } 140 | ``` 141 | 142 | ## Error Handling 143 | 144 | ### Selective Retry 145 | 146 | Don't retry errors that won't benefit from retrying: 147 | 148 | ```swift 149 | enum APIError: Error { 150 | case networkFailure // ✅ Retry 151 | case serverError // ✅ Retry (5xx) 152 | case timeout // ✅ Retry 153 | case rateLimited // ✅ Retry with delay 154 | case unauthorized // ❌ Don't retry (401) 155 | case forbidden // ❌ Don't retry (403) 156 | case notFound // ❌ Don't retry (404) 157 | case badRequest // ❌ Don't retry (400) 158 | case invalidData // ❌ Don't retry 159 | } 160 | 161 | func fetchWithSelectiveRetry() async throws -> Data { 162 | do { 163 | return try await retryService.retry({ 164 | try await performRequest() 165 | }, onFailure: { error in 166 | if let error = error as? APIError { 167 | switch error { 168 | case .unauthorized, .forbidden, .notFound, .badRequest, .invalidData: 169 | // Don't retry client errors 170 | throw error 171 | case .networkFailure, .serverError, .timeout, .rateLimited: 172 | // These were already retried 173 | throw error 174 | } 175 | }) 176 | } catch let error { 177 | throw error 178 | } 179 | } 180 | ``` 181 | 182 | ## Performance Optimization 183 | 184 | ### Avoid Over-Retrying 185 | 186 | Balance persistence with resource usage: 187 | 188 | ```swift 189 | // ❌ Bad - Too aggressive 190 | let badService = RetryPolicyService( 191 | strategy: .constant(retry: 100, duration: .milliseconds(10)) 192 | ) 193 | 194 | // ✅ Good - Reasonable limits 195 | let goodService = RetryPolicyService( 196 | strategy: .exponentialWithJitter( 197 | retry: 5, 198 | maxInterval: 60, 199 | duration: .seconds(1) 200 | ) 201 | ) 202 | ``` 203 | 204 | ### Set Appropriate Timeouts 205 | 206 | Combine retries with timeouts to prevent indefinite waiting: 207 | 208 | ```swift 209 | func fetchWithTimeout( 210 | timeout: TimeInterval, 211 | operation: @Sendable @escaping () async throws -> T 212 | ) async throws -> T { 213 | try await withThrowingTaskGroup(of: T.self) { group in 214 | group.addTask { 215 | try await retryService.retry(operation) 216 | } 217 | 218 | group.addTask { 219 | try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) 220 | throw TimeoutError.exceeded 221 | } 222 | 223 | let result = try await group.next()! 224 | group.cancelAll() 225 | return result 226 | } 227 | } 228 | ``` 229 | 230 | ## Testing 231 | 232 | ### Mock Retry Behavior 233 | 234 | Create testable retry scenarios: 235 | 236 | ```swift 237 | actor MockService { 238 | var failureCount: Int 239 | private var currentAttempt = 0 240 | 241 | init(failureCount: Int) { 242 | self.failureCount = failureCount 243 | } 244 | 245 | func operation() throws -> String { 246 | currentAttempt += 1 247 | if currentAttempt <= failureCount { 248 | throw MockError.transient 249 | } 250 | return "Success" 251 | } 252 | } 253 | 254 | // Test 255 | func testRetrySucceedsAfterFailures() async throws { 256 | let mock = MockService(failureCount: 2) 257 | let service = RetryPolicyService( 258 | strategy: .constant(retry: 3, duration: .milliseconds(10)) 259 | ) 260 | 261 | let result = try await service.retry { 262 | try await mock.operation() 263 | } 264 | 265 | XCTAssertEqual(result, "Success") 266 | } 267 | ``` 268 | 269 | ### Test Strategy Behavior 270 | 271 | Verify retry timing and attempts: 272 | 273 | ```swift 274 | func testExponentialBackoff() async throws { 275 | let startTime = Date() 276 | var attempts = 0 277 | 278 | do { 279 | try await retryService.retry { 280 | attempts += 1 281 | throw TestError.failed 282 | } 283 | } catch { 284 | // Expected to fail 285 | } 286 | 287 | let duration = Date().timeIntervalSince(startTime) 288 | 289 | XCTAssertEqual(attempts, 4) // Initial + 3 retries 290 | XCTAssertGreaterThan(duration, 7.0) // 1 + 2 + 4 seconds 291 | } 292 | ``` 293 | 294 | ## Common Pitfalls 295 | 296 | ### Don't Retry Non-Idempotent Operations 297 | 298 | Be careful with operations that shouldn't be repeated: 299 | 300 | ```swift 301 | // ❌ Dangerous - May create duplicate payments 302 | func processPayment(amount: Decimal) async throws { 303 | try await retryService.retry { 304 | try await paymentGateway.charge(amount) 305 | } 306 | } 307 | 308 | // ✅ Safe - Use idempotency key 309 | func processPayment(amount: Decimal, idempotencyKey: String) async throws { 310 | try await retryService.retry { 311 | try await paymentGateway.charge( 312 | amount: amount, 313 | idempotencyKey: idempotencyKey 314 | ) 315 | } 316 | } 317 | ``` 318 | 319 | ### Don't Ignore Cancellation 320 | 321 | Respect task cancellation: 322 | 323 | ```swift 324 | // ✅ Good - Check cancellation 325 | try await retryService.retry { 326 | try Task.checkCancellation() 327 | return try await operation() 328 | } 329 | 330 | // ❌ Bad - Ignores cancellation 331 | try await retryService.retry { 332 | return try await operation() // May continue after cancel 333 | } 334 | ``` 335 | 336 | ### Don't Nest Retries 337 | 338 | Avoid multiple retry layers: 339 | 340 | ```swift 341 | // ❌ Bad - Nested retries multiply attempts 342 | func fetch() async throws -> Data { 343 | try await outerRetry.retry { 344 | try await innerRetry.retry { // Don't do this! 345 | try await actualFetch() 346 | } 347 | } 348 | } 349 | 350 | // ✅ Good - Single retry layer 351 | func fetch() async throws -> Data { 352 | try await retryService.retry { 353 | try await actualFetch() 354 | } 355 | } 356 | ``` 357 | 358 | ## See Also 359 | 360 | - 361 | - 362 | 363 | - ``RetryPolicyService`` 364 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![A powerful retry policy service for Swift](./Resources/typhoon.png) 2 | 3 |

typhoon

4 | 5 |

6 | License 7 | Swift Compatibility 8 | Platform Compatibility 9 | CI 10 | 11 | 12 |

13 | 14 | ## Description 15 | Typhoon is a modern, lightweight Swift framework that provides elegant and robust retry policies for asynchronous operations. Built with Swift's async/await concurrency model, it helps you handle transient failures gracefully with configurable retry strategies. 16 | 17 | ## Features 18 | 19 | ✨ **Multiple Retry Strategies** - Constant, exponential, and exponential with jitter 20 | ⚡ **Async/Await Native** - Built for modern Swift concurrency 21 | 🎯 **Type-Safe** - Leverages Swift's type system for compile-time safety 22 | 🔧 **Configurable** - Flexible retry parameters for any use case 23 | 📱 **Cross-Platform** - Works on iOS, macOS, tvOS, watchOS, and visionOS 24 | ⚡ **Lightweight** - Minimal footprint with zero dependencies 25 | 🧪 **Well Tested** - Comprehensive test coverage 26 | 27 | ## Table of Contents 28 | 29 | - [Requirements](#requirements) 30 | - [Installation](#installation) 31 | - [Quick Start](#quick-start) 32 | - [Usage](#usage) 33 | - [Retry Strategies](#retry-strategies) 34 | - [Constant Strategy](#constant-strategy) 35 | - [Exponential Strategy](#exponential-strategy) 36 | - [Exponential with Jitter Strategy](#exponential-with-jitter-strategy) 37 | - [Common Use Cases](#common-use-cases) 38 | - [Communication](#communication) 39 | - [Documentation](#documentation) 40 | - [Contributing](#contributing) 41 | - [Development Setup](#development-setup) 42 | - [Author](#author) 43 | - [License](#license) 44 | 45 | ## Requirements 46 | 47 | | Platform | Minimum Version | 48 | |-----------|----------------| 49 | | iOS | 13.0+ | 50 | | macOS | 10.15+ | 51 | | tvOS | 13.0+ | 52 | | watchOS | 6.0+ | 53 | | visionOS | 1.0+ | 54 | | Xcode | 15.3+ | 55 | | Swift | 5.10+ | 56 | 57 | ## Installation 58 | 59 | ### Swift Package Manager 60 | 61 | Add the following dependency to your `Package.swift`: 62 | 63 | ```swift 64 | dependencies: [ 65 | .package(url: "https://github.com/space-code/typhoon.git", from: "1.4.0") 66 | ] 67 | ``` 68 | 69 | Or add it through Xcode: 70 | 71 | 1. File > Add Package Dependencies 72 | 2. Enter package URL: `https://github.com/space-code/typhoon.git` 73 | 3. Select version requirements 74 | 75 | ## Quick Start 76 | 77 | ```swift 78 | import Typhoon 79 | 80 | let retryService = RetryPolicyService( 81 | strategy: .constant(retry: 3, duration: .seconds(1)) 82 | ) 83 | 84 | do { 85 | let result = try await retryService.retry { 86 | try await fetchDataFromAPI() 87 | } 88 | print("✅ Success: \(result)") 89 | } catch { 90 | print("❌ Failed after retries: \(error)") 91 | } 92 | ``` 93 | 94 | ## Usage 95 | 96 | ### Retry Strategies 97 | 98 | Typhoon provides three powerful retry strategies to handle different failure scenarios: 99 | 100 | ```swift 101 | /// A retry strategy with a constant number of attempts and fixed duration between retries. 102 | case constant(retry: Int, duration: DispatchTimeInterval) 103 | 104 | /// A retry strategy with an exponential increase in duration between retries. 105 | case exponential(retry: Int, multiplier: Double = 2.0, duration: DispatchTimeInterval) 106 | 107 | /// A retry strategy with exponential increase in duration between retries and added jitter. 108 | case exponentialWithJitter( 109 | retry: Int, 110 | jitterFactor: Double = 0.1, 111 | maxInterval: DispatchTimeInterval? = .seconds(60), 112 | multiplier: Double = 2.0, 113 | duration: DispatchTimeInterval 114 | ) 115 | ``` 116 | 117 | ### Constant Strategy 118 | 119 | Best for scenarios where you want predictable, fixed delays between retries: 120 | 121 | ```swift 122 | import Typhoon 123 | 124 | // Retry up to 5 times with 2 seconds between each attempt 125 | let service = RetryPolicyService( 126 | strategy: .constant(retry: 5, duration: .seconds(2)) 127 | ) 128 | 129 | do { 130 | let data = try await service.retry { 131 | try await URLSession.shared.data(from: url) 132 | } 133 | } catch { 134 | print("Failed after 5 attempts") 135 | } 136 | ``` 137 | 138 | **Retry Timeline:** 139 | - Attempt 1: Immediate 140 | - Attempt 2: After 2 seconds 141 | - Attempt 3: After 2 seconds 142 | - Attempt 4: After 2 seconds 143 | - Attempt 5: After 2 seconds 144 | 145 | ### Exponential Strategy 146 | 147 | Ideal for avoiding overwhelming a failing service by progressively increasing wait times: 148 | 149 | ```swift 150 | import Typhoon 151 | 152 | // Retry up to 4 times with exponentially increasing delays 153 | let service = RetryPolicyService( 154 | strategy: .exponential( 155 | retry: 4, 156 | multiplier: 2.0, 157 | duration: .seconds(1) 158 | ) 159 | ) 160 | 161 | do { 162 | let response = try await service.retry { 163 | try await performNetworkRequest() 164 | } 165 | } catch { 166 | print("Request failed after exponential backoff") 167 | } 168 | ``` 169 | 170 | **Retry Timeline:** 171 | - Attempt 1: Immediate 172 | - Attempt 2: After 1 second (1 × 2⁰) 173 | - Attempt 3: After 2 seconds (1 × 2¹) 174 | - Attempt 4: After 4 seconds (1 × 2²) 175 | 176 | ### Exponential with Jitter Strategy 177 | 178 | The most sophisticated strategy, adding randomization to prevent thundering herd problems: 179 | 180 | ```swift 181 | import Typhoon 182 | 183 | // Retry with exponential backoff, jitter, and maximum interval cap 184 | let service = RetryPolicyService( 185 | strategy: .exponentialWithJitter( 186 | retry: 5, 187 | jitterFactor: 0.2, // Add ±20% randomization 188 | maxInterval: .seconds(30), // Cap at 30 seconds 189 | multiplier: 2.0, 190 | duration: .seconds(1) 191 | ) 192 | ) 193 | 194 | do { 195 | let result = try await service.retry { 196 | try await connectToDatabase() 197 | } 198 | } catch { 199 | print("Connection failed after sophisticated retry attempts") 200 | } 201 | ``` 202 | 203 | **Benefits of Jitter:** 204 | - Prevents multiple clients from retrying simultaneously 205 | - Reduces load spikes on recovering services 206 | - Improves overall system resilience 207 | 208 | ## Common Use Cases 209 | 210 | ### Network Requests 211 | 212 | ```swift 213 | import Typhoon 214 | 215 | class APIClient { 216 | private let retryService = RetryPolicyService( 217 | strategy: .exponential(retry: 3, duration: .milliseconds(500)) 218 | ) 219 | 220 | func fetchUser(id: String) async throws -> User { 221 | try await retryService.retry { 222 | let (data, _) = try await URLSession.shared.data( 223 | from: URL(string: "https://api.example.com/users/\(id)")! 224 | ) 225 | return try JSONDecoder().decode(User.self, from: data) 226 | } 227 | } 228 | } 229 | ``` 230 | 231 | ### Database Operations 232 | 233 | ```swift 234 | import Typhoon 235 | 236 | class DatabaseManager { 237 | private let retryService = RetryPolicyService( 238 | strategy: .exponentialWithJitter( 239 | retry: 5, 240 | jitterFactor: 0.15, 241 | maxInterval: .seconds(60), 242 | duration: .seconds(1) 243 | ) 244 | ) 245 | 246 | func saveRecord(_ record: Record) async throws { 247 | try await retryService.retry { 248 | try await database.insert(record) 249 | } 250 | } 251 | } 252 | ``` 253 | 254 | ### File Operations 255 | 256 | ```swift 257 | import Typhoon 258 | 259 | class FileService { 260 | private let retryService = RetryPolicyService( 261 | strategy: .constant(retry: 3, duration: .milliseconds(100)) 262 | ) 263 | 264 | func writeFile(data: Data, to path: String) async throws { 265 | try await retryService.retry { 266 | try data.write(to: URL(fileURLWithPath: path)) 267 | } 268 | } 269 | } 270 | ``` 271 | 272 | ### Third-Party Service Integration 273 | 274 | ```swift 275 | import Typhoon 276 | 277 | class PaymentService { 278 | private let retryService = RetryPolicyService( 279 | strategy: .exponential( 280 | retry: 4, 281 | multiplier: 1.5, 282 | duration: .seconds(2) 283 | ) 284 | ) 285 | 286 | func processPayment(amount: Decimal) async throws -> PaymentResult { 287 | try await retryService.retry { 288 | try await paymentGateway.charge(amount: amount) 289 | } 290 | } 291 | } 292 | ``` 293 | 294 | ## Communication 295 | 296 | - 🐛 **Found a bug?** [Open an issue](https://github.com/space-code/typhoon/issues/new) 297 | - 💡 **Have a feature request?** [Open an issue](https://github.com/space-code/typhoon/issues/new) 298 | - ❓ **Questions?** [Start a discussion](https://github.com/space-code/typhoon/discussions) 299 | - 🔒 **Security issue?** Email nv3212@gmail.com 300 | 301 | ## Documentation 302 | 303 | Comprehensive documentation is available: [Typhoon Documentation](https://space-code.github.io/typhoon/typhoon/documentation/typhoon/) 304 | 305 | ## Contributing 306 | 307 | We love contributions! Please feel free to help out with this project. If you see something that could be made better or want a new feature, open up an issue or send a Pull Request. 308 | 309 | ### Development Setup 310 | 311 | Bootstrap the development environment: 312 | 313 | ```bash 314 | mise install 315 | ``` 316 | 317 | ## Author 318 | 319 | **Nikita Vasilev** 320 | - Email: nv3212@gmail.com 321 | - GitHub: [@ns-vasilev](https://github.com/ns-vasilev) 322 | 323 | ## License 324 | 325 | Typhoon is released under the MIT license. See [LICENSE](https://github.com/space-code/typhoon/blob/main/LICENSE) for details. 326 | 327 | --- 328 | 329 |
330 | 331 | **[⬆ back to top](#typhoon)** 332 | 333 | Made with ❤️ by [space-code](https://github.com/space-code) 334 | 335 |
-------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ configuration file 2 | # https://git-cliff.org/docs/configuration 3 | 4 | [remote.github] 5 | owner = "space-code" 6 | repo = "typhoon" 7 | # If you are using a token to fetch GitHub usernames, uncomment below: 8 | # token = "${GITHUB_TOKEN}" 9 | 10 | [changelog] 11 | # GUARANTEE: Skip releases if they contain no commits (for tagged versions) 12 | skip_empty_releases = true 13 | # Maximum number of releases to display in the changelog 14 | # number_of_releases = 10 15 | 16 | header = """ 17 | # Changelog 18 | 19 | All notable changes to this project will be documented in this file. 20 | 21 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 22 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 23 | 24 | {#- LOGIC: Generate table of contents - group versions by major version -#} 25 | {%- set_global major_versions = [] -%} 26 | {%- for release in releases -%} 27 | {%- if release.version -%} 28 | {%- set version_clean = release.version | trim_start_matches(pat="v") -%} 29 | {%- set major = version_clean | split(pat=".") | first -%} 30 | {%- if major not in major_versions -%} 31 | {%- set_global major_versions = major_versions | concat(with=major) -%} 32 | {%- endif -%} 33 | {%- endif -%} 34 | {%- endfor -%} 35 | {%- set sorted_majors = major_versions | sort | reverse -%} 36 | 37 | {#- MAIN LOOP: Iterate over major versions -#} 38 | {%- for major in sorted_majors -%} 39 | {#- VISUAL: Add double newline before the header to separate from previous block -#} 40 | {{ "\n\n" }}#### {{ major }}.x Releases 41 | 42 | {#- LOGIC: Filter releases for the current major version -#} 43 | {%- set_global major_releases = [] -%} 44 | {%- for release in releases -%} 45 | {%- if release.version -%} 46 | {%- set version_clean = release.version | trim_start_matches(pat="v") -%} 47 | {%- set rel_major = version_clean | split(pat=".") | first -%} 48 | {%- if rel_major == major -%} 49 | {%- set_global major_releases = major_releases | concat(with=version_clean) -%} 50 | {%- endif -%} 51 | {%- endif -%} 52 | {%- endfor -%} 53 | 54 | {#- LOGIC: Separate into Stable, RC, and Beta -#} 55 | {%- set_global stable_versions = [] -%} 56 | {%- set_global rc_versions = [] -%} 57 | {%- set_global beta_versions = [] -%} 58 | {%- for version in major_releases -%} 59 | {%- if version is containing("-rc") -%} 60 | {%- set_global rc_versions = rc_versions | concat(with=version) -%} 61 | {%- elif version is containing("-beta") -%} 62 | {%- set_global beta_versions = beta_versions | concat(with=version) -%} 63 | {%- else -%} 64 | {%- set_global stable_versions = stable_versions | concat(with=version) -%} 65 | {%- endif -%} 66 | {%- endfor -%} 67 | 68 | {#- LOGIC: Group stable versions by minor version -#} 69 | {%- set_global minor_versions = [] -%} 70 | {%- for version in stable_versions -%} 71 | {%- set parts = version | split(pat=".") -%} 72 | {%- set minor_key = parts | slice(end=2) | join(sep=".") -%} 73 | {%- if minor_key not in minor_versions -%} 74 | {%- set_global minor_versions = minor_versions | concat(with=minor_key) -%} 75 | {%- endif -%} 76 | {%- endfor -%} 77 | {%- set sorted_minors = minor_versions | sort | reverse -%} 78 | 79 | {#- OUTPUT: Stable releases -#} 80 | {%- for minor_key in sorted_minors -%} 81 | {%- set_global minor_release_versions = [] -%} 82 | {%- for version in stable_versions -%} 83 | {%- set parts = version | split(pat=".") -%} 84 | {%- set ver_minor = parts | slice(end=2) | join(sep=".") -%} 85 | {%- if ver_minor == minor_key -%} 86 | {%- set_global minor_release_versions = minor_release_versions | concat(with=version) -%} 87 | {%- endif -%} 88 | {%- endfor -%} 89 | {%- set versions_list = minor_release_versions | sort | reverse -%} 90 | {{ "\n" }}- `{{ minor_key }}.x` Releases - {% for version in versions_list -%} 91 | [{{ version }}](#{{ version | replace(from=".", to="") | replace(from="-", to="") | lower }}) 92 | {%- if not loop.last %} | {% endif -%} 93 | {%- endfor -%} 94 | {%- endfor -%} 95 | 96 | {#- OUTPUT: RC versions -#} 97 | {%- if rc_versions | length > 0 -%} 98 | {%- set rc_versions_sorted = rc_versions | sort | reverse -%} 99 | {%- set rc_base = rc_versions_sorted | first | split(pat="-") | first -%} 100 | {{ "\n" }}- `{{ rc_base }}` Release Candidates - {% for version in rc_versions_sorted -%} 101 | [{{ version }}](#{{ version | replace(from=".", to="") | replace(from="-", to="") | lower }}) 102 | {%- if not loop.last %} | {% endif -%} 103 | {%- endfor -%} 104 | {%- endif -%} 105 | 106 | {#- OUTPUT: Beta versions -#} 107 | {%- if beta_versions | length > 0 -%} 108 | {%- set beta_versions_sorted = beta_versions | sort | reverse -%} 109 | {%- set beta_base = beta_versions_sorted | first | split(pat="-") | first -%} 110 | {{ "\n" }}- `{{ beta_base }}` Betas - {% for version in beta_versions_sorted -%} 111 | [{{ version }}](#{{ version | replace(from=".", to="") | replace(from="-", to="") | lower }}) 112 | {%- if not loop.last %} | {% endif -%} 113 | {%- endfor -%} 114 | {%- endif -%} 115 | {%- endfor -%}{{ "\n" }} 116 | --- 117 | """ 118 | 119 | body = """ 120 | {%- macro remote_url() -%} 121 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 122 | {%- endmacro -%} 123 | 124 | {%- set_global renderable_commits = [] -%} 125 | {%- for commit in commits -%} 126 | {# Filter commits that have a Conventional Commit type or a PR number (your rendering condition) #} 127 | {%- if commit.conventional or commit.remote.pr_number -%} 128 | {%- set_global renderable_commits = renderable_commits | concat(with=commit) -%} 129 | {%- endif -%} 130 | {%- endfor -%} 131 | 132 | {%- if renderable_commits | length > 0 -%} 133 | {#- LOGIC: Version Header with Link -#} 134 | {%- if version -%} 135 | {{ "\n" }} 136 | ## [{{ version | trim_start_matches(pat="v") }}]({{ self::remote_url() }}/releases/tag/{{ version | trim_start_matches(pat="v") }}) 137 | {{ "\n" }}Released on {{ timestamp | date(format="%Y-%m-%d") }}. All issues associated with this milestone can be found using this [filter]({{ self::remote_url() }}/milestones?state=closed&q={{ version }}). 138 | {%- else -%} 139 | ## [Unreleased] 140 | {%- endif -%} 141 | 142 | {#- LOGIC: Loop through commit groups -#} 143 | {%- for group, commits_in_group in renderable_commits | group_by(attribute="group") -%} 144 | {%- if group == "Uncategorized Changes" and commits_in_group | length == 0 -%} 145 | {%- continue -%} 146 | {%- endif -%} 147 | 148 | {# We also check that it is not the system group 'Other', which might be empty #} 149 | {%- if group == "Other" and commits_in_group | length == 0 -%} 150 | {%- continue -%} 151 | {%- endif -%} 152 | 153 | {%- set action_verb = "Contributed by" -%} 154 | {%- if group == "Features" -%} 155 | {%- set action_verb = "Implemented by" -%} 156 | {%- elif group == "Bug Fixes" -%} 157 | {%- set action_verb = "Fixed by" -%} 158 | {%- elif group == "Performance" -%} 159 | {%- set action_verb = "Optimized by" -%} 160 | {%- elif group == "Documentation" -%} 161 | {%- set action_verb = "Documented by" -%} 162 | {%- endif -%} 163 | 164 | {{ "\n" }}{{ "\n" }}### {{ group | upper_first }} 165 | 166 | {#- THE LOOP NOW USES FILTERED COMMITS AND DOESN'T NEED AN INNER IF -#} 167 | {%- for commit in commits_in_group -%} 168 | {%- set message = commit.message | split(pat="\n") | first | upper_first | trim -%} 169 | 170 | {#- VISUAL: Commit message line -#} 171 | {{ "\n" }}- {{ message }} 172 | 173 | {%- if commit.remote.username and commit.remote.pr_number -%} 174 | {#- VISUAL: Dynamic verb line -#} 175 | {{ "\n" }} - {{ action_verb }} [@{{ commit.remote.username }}](https://github.com/{{ commit.remote.username }}) in Pull Request [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}). 176 | {%- endif -%} 177 | {%- endfor -%} 178 | {%- endfor -%} 179 | 180 | {#- LOGIC: New Contributors Section -#} 181 | {%- set new_contributors = github.contributors | filter(attribute="is_first_time", value=true) -%} 182 | 183 | {%- if new_contributors | length > 0 -%} 184 | {%- set_global real_new_contributors = [] -%} 185 | {%- for contributor in new_contributors -%} 186 | {#- IMPORTANT: Filtering out your login "ns-vasilev" and Renovate -#} 187 | {%- set username_lower = contributor.username | default(value="") | lower | trim -%} 188 | {%- if username_lower != "ns-vasilev" and username_lower != "renovate" -%} 189 | {%- set_global real_new_contributors = real_new_contributors | concat(with=contributor) -%} 190 | {%- endif -%} 191 | {%- endfor -%} 192 | 193 | {%- if real_new_contributors | length > 0 -%} 194 | {{ "\n" }}{{ "\n" }}### New Contributors 195 | {%- for contributor in real_new_contributors -%} 196 | {{ "\n" }}* @{{ contributor.username }} made their first contribution in{{ " " }} 197 | {%- if contributor.pr_number -%} 198 | [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) 199 | {%- endif -%} 200 | {%- endfor -%} 201 | {%- endif -%} 202 | {%- endif -%} 203 | {%- endif -%} 204 | """ 205 | 206 | footer = """ 207 | {%- macro remote_url() -%} 208 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 209 | {%- endmacro -%} 210 | 211 | {{ "\n" }} 212 | {% for release in releases -%} 213 | {% if release.version -%} 214 | {% if release.previous.version -%} 215 | [{{ release.version | trim_start_matches(pat="v") }}]: \ 216 | {{ self::remote_url() }}/compare/{{ release.previous.version }}..{{ release.version }} 217 | {% endif -%} 218 | {% else -%} 219 | [unreleased]: {{ self::remote_url() }}/compare/{{ release.previous.version }}..HEAD 220 | {% endif -%} 221 | {% endfor %} 222 | """ 223 | trim = true 224 | postprocessors = [] 225 | 226 | [git] 227 | conventional_commits = true 228 | # SET TO false to include old (unconventional) commits 229 | filter_unconventional = false 230 | split_commits = false 231 | commit_preprocessors = [ 232 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }, 233 | ] 234 | commit_parsers = [ 235 | { message = "^chore\\(changelog\\)", skip = true }, 236 | { message = "^chore.*changelog", skip = true }, 237 | { message = "^docs: update CHANGELOG\\.md \\[skip ci\\]$", skip = true }, 238 | { message = "^feat", group = "Features" }, 239 | { message = "^fix", group = "Bug Fixes" }, 240 | { message = "^doc", group = "Documentation" }, 241 | { message = "^perf", group = "Performance" }, 242 | { message = "^refactor", group = "Refactor" }, 243 | { message = "^style", group = "Styling" }, 244 | { message = "^test", group = "Testing" }, 245 | { message = "^chore\\(spm.*\\)", skip = false }, 246 | { message = "^chore\\(deps.*\\)", skip = true }, 247 | { message = "^chore\\(pr\\)", skip = true }, 248 | { message = "^chore\\(pull\\)", skip = true }, 249 | { message = "^chore\\(release\\): prepare for", skip = true }, 250 | { message = "^chore|^ci", group = "Miscellaneous Tasks" }, 251 | { body = ".*security", group = "Security" }, 252 | # CATCH-ALL PARSER for old (unconventional) commits 253 | { message = ".*", group = "Uncategorized Changes" }, 254 | ] 255 | 256 | # SET TO false to avoid filtering out Uncategorized Changes 257 | filter_commits = false 258 | protect_breaking_commits = false 259 | tag_pattern = "^[0-9].*" 260 | skip_tags = "beta|alpha|cli-.*" 261 | ignore_tags = "rc|web-.*" 262 | topo_order = false 263 | sort_commits = "newest" 264 | 265 | [bump] 266 | breaking_always_bump_major = true 267 | features_always_bump_minor = true 268 | initial_tag = "0.1.0" -------------------------------------------------------------------------------- /Sources/Typhoon/Typhoon.docc/Articles/advanced-retry-strategies.md: -------------------------------------------------------------------------------- 1 | 2 | # Advanced Retry Strategies 3 | 4 | Master advanced retry patterns and optimization techniques. 5 | 6 | ## Overview 7 | 8 | This guide covers advanced usage patterns, performance optimization, and sophisticated retry strategies for complex scenarios. 9 | 10 | ## How Retry Mechanism Works 11 | 12 | Understanding the retry flow is crucial for effective error handling: 13 | 14 | ```swift 15 | // Configuration: retry: 3 means 3 RETRY attempts 16 | let strategy = RetryStrategy.exponential( 17 | retry: 3, 18 | multiplier: 2.0, 19 | duration: .seconds(1) 20 | ) 21 | ``` 22 | 23 | **Total Execution Flow:** 24 | 25 | | Attempt Type | Attempt # | Delay Before | Description | 26 | |--------------|-----------|--------------|-------------| 27 | | Initial | 1 | 0s | First execution (not a retry) | 28 | | Retry | 2 | 1s | First retry after failure | 29 | | Retry | 3 | 2s | Second retry after failure | 30 | | Retry | 4 | 4s | Third retry after failure | 31 | 32 | **Key Points:** 33 | - The `retry` parameter specifies the number of **retry attempts**, not total attempts 34 | - Total attempts = 1 (initial) + N (retries) 35 | - `retry: 3` means **4 total attempts** (1 initial + 3 retries) 36 | - `onFailure` callback is invoked after **every** failed attempt, including the initial one 37 | 38 | ## Strategy Deep Dive 39 | 40 | ### Understanding Exponential Backoff 41 | 42 | Exponential backoff progressively increases wait times to avoid overwhelming recovering services: 43 | 44 | ```swift 45 | let strategy = RetryStrategy.exponential( 46 | retry: 5, // 5 retry attempts 47 | multiplier: 2.0, 48 | duration: .seconds(1) 49 | ) 50 | ``` 51 | 52 | **Calculation:** `delay = baseDuration × multiplier^(attemptNumber - 1)` 53 | 54 | | Attempt Type | Total Attempt | Calculation | Delay Before | 55 | |--------------|---------------|-------------|--------------| 56 | | Initial | 1 | - | 0s (immediate) | 57 | | Retry 1 | 2 | 1 × 2⁰ | 1s | 58 | | Retry 2 | 3 | 1 × 2¹ | 2s | 59 | | Retry 3 | 4 | 1 × 2² | 4s | 60 | | Retry 4 | 5 | 1 × 2³ | 8s | 61 | | Retry 5 | 6 | 1 × 2⁴ | 16s | 62 | 63 | **Total: 6 attempts (1 initial + 5 retries)** 64 | 65 | **Multiplier effects:** 66 | 67 | ```swift 68 | // Aggressive backoff (multiplier: 3.0) 69 | // Initial: 0s → Retry: 1s → 3s → 9s → 27s → 81s 70 | 71 | // Moderate backoff (multiplier: 1.5) 72 | // Initial: 0s → Retry: 1s → 1.5s → 2.25s → 3.375s → 5.0625s 73 | 74 | // Slow backoff (multiplier: 1.2) 75 | // Initial: 0s → Retry: 1s → 1.2s → 1.44s → 1.728s → 2.074s 76 | ``` 77 | 78 | ### Jitter: Preventing Thundering Herd 79 | 80 | When multiple clients retry simultaneously, they can overwhelm a recovering service. Jitter adds randomization to prevent this: 81 | 82 | ```swift 83 | let strategy = RetryStrategy.exponentialWithJitter( 84 | retry: 5, // 5 retry attempts 85 | jitterFactor: 0.2, // ±20% randomization 86 | maxInterval: .seconds(30), // Cap at 30 seconds 87 | multiplier: 2.0, 88 | duration: .seconds(1) 89 | ) 90 | ``` 91 | 92 | **Without jitter:** 93 | ``` 94 | Client 1: 0s(init) → 1s → 2s → 4s → 8s → 16s 95 | Client 2: 0s(init) → 1s → 2s → 4s → 8s → 16s 96 | Client 3: 0s(init) → 1s → 2s → 4s → 8s → 16s 97 | All hit server simultaneously! 💥 98 | ``` 99 | 100 | **With jitter:** 101 | ``` 102 | Client 1: 0s(init) → 0.9s → 2.1s → 3.8s → 8.2s → 15.7s 103 | Client 2: 0s(init) → 1.1s → 1.9s → 4.3s → 7.7s → 16.4s 104 | Client 3: 0s(init) → 0.8s → 2.2s → 3.9s → 8.1s → 15.8s 105 | Traffic spread out! ✅ 106 | ``` 107 | 108 | ### Maximum Interval Capping 109 | 110 | Prevent delays from growing unbounded: 111 | 112 | ```swift 113 | .exponentialWithJitter( 114 | retry: 10, // 10 retry attempts = 11 total 115 | jitterFactor: 0.1, 116 | maxInterval: .seconds(60), // Never wait more than 60 seconds 117 | multiplier: 2.0, 118 | duration: .seconds(1) 119 | ) 120 | ``` 121 | 122 | **Without cap:** 123 | Initial → 1s → 2s → 4s → 8s → 16s → 32s → 64s → 128s → 256s → 512s 124 | 125 | **With 60s cap:** 126 | Initial → 1s → 2s → 4s → 8s → 16s → 32s → 60s → 60s → 60s → 60s 127 | 128 | ## Advanced Patterns 129 | 130 | ### Conditional Retry Logic 131 | 132 | Retry only for specific error types: 133 | 134 | ```swift 135 | enum NetworkError: Error { 136 | case serverError 137 | case clientError 138 | case timeout 139 | case connectionLost 140 | } 141 | 142 | func fetchWithConditionalRetry() async throws -> Data { 143 | let retryService = RetryPolicyService( 144 | strategy: .exponential(retry: 3, duration: .seconds(1)) 145 | ) 146 | 147 | do { 148 | 149 | return try await retryService.retry({ 150 | try await performRequest() 151 | }, onFailure: { error in 152 | if let error = error as? NetworkError { 153 | switch error { 154 | case .serverError, .timeout, .connectionLost: 155 | // These errors were already retried 156 | return true 157 | case .clientError: 158 | // Don't retry client errors (4xx) 159 | return false 160 | } 161 | } 162 | 163 | return true 164 | }) 165 | } catch let error as RetryPolicyError { 166 | switch error { 167 | case .retryLimitExceeded: 168 | // All retry attempts exhausted 169 | print("Retry limit exceeded after multiple attempts") 170 | throw error 171 | } 172 | } 173 | } 174 | ``` 175 | 176 | ### Retry with Timeout 177 | 178 | Combine retry logic with overall timeout: 179 | 180 | ```swift 181 | func fetchWithTimeout() async throws -> Data { 182 | let retryService = RetryPolicyService( 183 | strategy: .exponential(retry: 5, duration: .seconds(1)) 184 | ) 185 | 186 | return try await withThrowingTaskGroup(of: Data.self) { group in 187 | // Add retry task 188 | group.addTask { 189 | try await retryService.retry { 190 | try await performRequest() 191 | } 192 | } 193 | 194 | // Add timeout task 195 | group.addTask { 196 | try await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds 197 | throw TimeoutError.exceeded 198 | } 199 | 200 | // Return first result or throw first error 201 | guard let result = try await group.next() else { 202 | throw TimeoutError.unknown 203 | } 204 | 205 | group.cancelAll() 206 | return result 207 | } 208 | } 209 | ``` 210 | 211 | ### Adaptive Retry Strategy 212 | 213 | Adjust strategy based on error patterns: 214 | 215 | ```swift 216 | actor AdaptiveRetryService { 217 | private var consecutiveFailures = 0 218 | private let maxConsecutiveFailures = 3 219 | 220 | func retry(_ operation: @Sendable () async throws -> T) async throws -> T { 221 | let strategy = selectStrategy() 222 | let retryService = RetryPolicyService(strategy: strategy) 223 | 224 | do { 225 | let result = try await retryService.retry(operation) 226 | consecutiveFailures = 0 227 | return result 228 | } catch { 229 | consecutiveFailures += 1 230 | throw error 231 | } 232 | } 233 | 234 | private func selectStrategy() -> RetryPolicyStrategy { 235 | if consecutiveFailures >= maxConsecutiveFailures { 236 | // System under stress - use conservative strategy 237 | // 1 initial + 3 retries with longer delays 238 | return .exponentialWithJitter( 239 | retry: 3, 240 | jitterFactor: 0.3, 241 | maxInterval: 120, 242 | multiplier: 3.0, 243 | duration: .seconds(5) 244 | ) 245 | } else { 246 | // Normal operation - use standard strategy 247 | // 1 initial + 4 retries 248 | return .exponential( 249 | retry: 4, 250 | multiplier: 2.0, 251 | duration: .seconds(1) 252 | ) 253 | } 254 | } 255 | } 256 | ``` 257 | 258 | ### Retry with Progress Tracking 259 | 260 | Monitor retry attempts: 261 | 262 | ```swift 263 | actor RetryProgressTracker { 264 | var onRetry: ((Int, Error) async -> Void)? 265 | 266 | private let maxRetries = 5 267 | 268 | private final class AttemptCounter { 269 | var count: Int = 0 270 | } 271 | 272 | func fetchWithProgress( 273 | operation: @escaping () async throws -> T 274 | ) async throws -> T { 275 | 276 | let counter = AttemptCounter() 277 | 278 | let retryService = RetryPolicyService( 279 | strategy: .exponential(retry: maxRetries, duration: .seconds(1)) 280 | ) 281 | 282 | return try await retryService.retry { 283 | counter.count += 1 284 | 285 | do { 286 | return try await operation() 287 | } catch { 288 | if counter.count < self.maxRetries { 289 | await self.notifyOnRetry(attemptCount: counter.count, error: error) 290 | } 291 | 292 | throw error 293 | } 294 | } 295 | } 296 | 297 | private func notifyOnRetry(attemptCount: Int, error: Error) async { 298 | await onRetry?(attemptCount, error) 299 | } 300 | } 301 | 302 | // Usage 303 | let tracker = RetryProgressTracker() 304 | tracker.onRetry = { attempt, error in 305 | print("Retry attempt \(attempt) after error: \(error)") 306 | } 307 | 308 | let data = try await tracker.fetchWithProgress { 309 | try await fetchFromAPI() 310 | } 311 | ``` 312 | 313 | ## Performance Optimization 314 | 315 | ### Choosing the Right Strategy 316 | 317 | | Scenario | Strategy | Rationale | 318 | |----------|----------|-----------| 319 | | Fast local operations | Constant (3, 100ms) | Quick retries for transient issues | 320 | | Network requests | Exponential (4, 500ms-1s) | Give service time to recover | 321 | | High-traffic APIs | Exponential with jitter | Prevent synchronized retries | 322 | | Critical operations | Exponential with jitter + cap | Balance persistence with resource usage | 323 | | Rate-limited APIs | Constant with long delay | Respect rate limits | 324 | 325 | ### Memory Management 326 | 327 | Typhoon is designed to be memory-efficient: 328 | 329 | ```swift 330 | // ✅ Good - Reuse service instance 331 | class DataRepository { 332 | private let retryService = RetryPolicyService( 333 | strategy: .exponential(retry: 3, duration: .seconds(1)) 334 | ) 335 | 336 | func fetchData() async throws -> Data { 337 | try await retryService.retry { 338 | try await performFetch() 339 | } 340 | } 341 | } 342 | 343 | // ❌ Avoid - Creating new instances repeatedly 344 | func fetchData() async throws -> Data { 345 | let retryService = RetryPolicyService( // Creates new instance each time 346 | strategy: .exponential(retry: 3, duration: .seconds(1)) 347 | ) 348 | return try await retryService.retry { 349 | try await performFetch() 350 | } 351 | } 352 | ``` 353 | 354 | ### Cancellation Support 355 | 356 | Typhoon respects task cancellation: 357 | 358 | ```swift 359 | let task = Task { 360 | let retryService = RetryPolicyService( 361 | strategy: .exponential(retry: 10, duration: .seconds(5)) 362 | ) 363 | 364 | return try await retryService.retry { 365 | try Task.checkCancellation() // Respects cancellation 366 | return try await longRunningOperation() 367 | } 368 | } 369 | 370 | // Cancel after 10 seconds 371 | Task { 372 | try await Task.sleep(nanoseconds: 10_000_000_000) 373 | task.cancel() 374 | } 375 | ``` 376 | 377 | ## Testing Strategies 378 | 379 | ### Mock Retry Behavior 380 | 381 | ```swift 382 | class MockRetryService { 383 | var shouldSucceedAfter: Int = 2 384 | private var attemptCount = 0 385 | 386 | func simulateRetry() async throws -> String { 387 | attemptCount += 1 388 | 389 | if attemptCount < shouldSucceedAfter { 390 | throw MockError.transient 391 | } 392 | 393 | return "Success" 394 | } 395 | } 396 | 397 | // Test 398 | let mock = MockRetryService() 399 | mock.shouldSucceedAfter = 3 400 | 401 | let retryService = RetryPolicyService( 402 | strategy: .constant(retry: 5, duration: .milliseconds(10)) 403 | ) 404 | 405 | let result = try await retryService.retry { 406 | try await mock.simulateRetry() 407 | } 408 | 409 | XCTAssertEqual(result, "Success") 410 | ``` 411 | 412 | ## See Also 413 | 414 | - 415 | - 416 | 417 | - ``RetryPolicyService`` 418 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Typhoon 2 | 3 | First off, thank you for considering contributing to Typhoon! It's people like you that make Typhoon such a great tool. 4 | 5 | ## Table of Contents 6 | 7 | - [Code of Conduct](#code-of-conduct) 8 | - [Getting Started](#getting-started) 9 | - [Development Setup](#development-setup) 10 | - [Project Structure](#project-structure) 11 | - [How Can I Contribute?](#how-can-i-contribute) 12 | - [Reporting Bugs](#reporting-bugs) 13 | - [Suggesting Features](#suggesting-features) 14 | - [Improving Documentation](#improving-documentation) 15 | - [Submitting Code](#submitting-code) 16 | - [Development Workflow](#development-workflow) 17 | - [Branching Strategy](#branching-strategy) 18 | - [Commit Guidelines](#commit-guidelines) 19 | - [Pull Request Process](#pull-request-process) 20 | - [Coding Standards](#coding-standards) 21 | - [Swift Style Guide](#swift-style-guide) 22 | - [Code Quality](#code-quality) 23 | - [Testing Requirements](#testing-requirements) 24 | - [Community](#community) 25 | 26 | ## Code of Conduct 27 | 28 | This project and everyone participating in it is governed by our Code of Conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to nv3212@gmail.com. 29 | 30 | ## Getting Started 31 | 32 | ### Development Setup 33 | 34 | 1. **Fork the repository** 35 | ```bash 36 | # Click the "Fork" button on GitHub 37 | ``` 38 | 39 | 2. **Clone your fork** 40 | ```bash 41 | git clone https://github.com/YOUR_USERNAME/typhoon.git 42 | cd typhoon 43 | ``` 44 | 45 | 3. **Set up the development environment** 46 | ```bash 47 | # Bootstrap the project 48 | make bootstrap 49 | ``` 50 | 51 | 4. **Create a feature branch** 52 | ```bash 53 | git checkout -b feature/your-feature-name 54 | ``` 55 | 56 | 5. **Open the project in Xcode** 57 | ```bash 58 | open Package.swift 59 | ``` 60 | 61 | ## How Can I Contribute? 62 | 63 | ### Reporting Bugs 64 | 65 | Before creating a bug report, please check the [existing issues](https://github.com/space-code/typhoon/issues) to avoid duplicates. 66 | 67 | When creating a bug report, include: 68 | 69 | - **Clear title** - Describe the issue concisely 70 | - **Reproduction steps** - Detailed steps to reproduce the bug 71 | - **Expected behavior** - What you expected to happen 72 | - **Actual behavior** - What actually happened 73 | - **Environment** - OS, Xcode version, Swift version 74 | - **Code samples** - Minimal reproducible example 75 | - **Error messages** - Complete error output if applicable 76 | 77 | **Example:** 78 | ```markdown 79 | **Title:** ExponentialWithJitter strategy returns incorrect delay 80 | 81 | **Steps to reproduce:** 82 | 1. Create RetryPolicyService with exponentialWithJitter strategy 83 | 2. Set maxInterval to 30 seconds 84 | 3. Observe delays exceeding maxInterval 85 | 86 | **Expected:** Delays should never exceed 30 seconds 87 | **Actual:** Delays can reach 60+ seconds 88 | 89 | **Environment:** 90 | - iOS 16.0 91 | - Xcode 15.3 92 | - Swift 5.10 93 | 94 | **Code:** 95 | \`\`\`swift 96 | let service = RetryPolicyService( 97 | strategy: .exponentialWithJitter( 98 | retry: 5, 99 | maxInterval: .seconds(30), 100 | duration: .seconds(1) 101 | ) 102 | ) 103 | \`\`\` 104 | ``` 105 | 106 | ### Suggesting Features 107 | 108 | We love feature suggestions! When proposing a new feature, include: 109 | 110 | - **Problem statement** - What problem does this solve? 111 | - **Proposed solution** - How should it work? 112 | - **Alternatives** - What alternatives did you consider? 113 | - **Use cases** - Real-world scenarios 114 | - **API design** - Example code showing usage 115 | - **Breaking changes** - Will this break existing code? 116 | 117 | **Example:** 118 | ```markdown 119 | **Feature:** Add adaptive retry strategy 120 | 121 | **Problem:** Current strategies are static. Applications need dynamic adjustment based on error patterns. 122 | 123 | **Solution:** Add `.adaptive()` strategy that adjusts backoff based on success/failure rates. 124 | 125 | **API:** 126 | \`\`\`swift 127 | let service = RetryPolicyService( 128 | strategy: .adaptive( 129 | minRetry: 2, 130 | maxRetry: 10, 131 | adaptiveFactor: 1.5 132 | ) 133 | ) 134 | \`\`\` 135 | 136 | **Use case:** Mobile app that needs aggressive retries on good connections but conservative retries on poor connections. 137 | ``` 138 | 139 | ### Improving Documentation 140 | 141 | Documentation improvements are always welcome: 142 | 143 | - **Code comments** - Add/improve inline documentation 144 | - **DocC documentation** - Enhance documentation articles 145 | - **README** - Fix typos, add examples 146 | - **Guides** - Write tutorials or how-to guides 147 | - **API documentation** - Document public APIs 148 | 149 | ### Submitting Code 150 | 151 | 1. **Check existing work** - Look for related issues or PRs 152 | 2. **Discuss major changes** - Open an issue for large features 153 | 3. **Follow coding standards** - See [Coding Standards](#coding-standards) 154 | 4. **Write tests** - All code changes require tests 155 | 5. **Update documentation** - Keep docs in sync with code 156 | 6. **Create a pull request** - Use clear description 157 | 158 | ## Development Workflow 159 | 160 | ### Branching Strategy 161 | 162 | We use a simplified branching model: 163 | 164 | - **`main`** - Main development branch (all PRs target this) 165 | - **`feature/*`** - New features 166 | - **`fix/*`** - Bug fixes 167 | - **`docs/*`** - Documentation updates 168 | - **`refactor/*`** - Code refactoring 169 | - **`test/*`** - Test improvements 170 | 171 | **Branch naming examples:** 172 | ```bash 173 | feature/adaptive-retry-strategy 174 | fix/exponential-jitter-calculation 175 | docs/update-quick-start-guide 176 | refactor/simplify-delay-calculation 177 | test/add-retry-sequence-tests 178 | ``` 179 | 180 | ### Commit Guidelines 181 | 182 | We use [Conventional Commits](https://www.conventionalcommits.org/) for clear, structured commit history. 183 | 184 | **Format:** 185 | ``` 186 | (): 187 | 188 | 189 | 190 |