Skip to content

Commit 14d9b95

Browse files
Add RetryingClientMiddleware example (#433)
### Motivation Adds a retrying middleware example. ### Modifications Ditto. ### Result Now we can point to how to do retries in a middleware, the part about potentially calling `next` multiple times might not be immediately obvious, so it's an important example to show. ### Test Plan Tested manually by tweaking a local server to return 500 randomly and saw the retry work. --------- Co-authored-by: Si Beaumont <[email protected]>
1 parent 32b4154 commit 14d9b95

File tree

8 files changed

+307
-0
lines changed

8 files changed

+307
-0
lines changed

Examples/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ The following packages show working with various content types, such as JSON, UR
2323
- [`SwaggerUIEndpointsServer`](./SwaggerUIEndpointsServer) - a server that vends its OpenAPI document as a raw file and also provides a rendered documentation viewer using swagger-ui.
2424
- [`PostgresDatabaseServer`](./PostgresDatabaseServer) - a server using Postgres for persistent state.
2525

26+
## Middleware
27+
28+
- [`LoggingMiddlewareOSLog`](./LoggingMiddlewareOSLog) - a client middleware that logs requests and responses using OSLog.
29+
- [`LoggingMiddlewareSwiftLog`](./LoggingMiddlewareSwiftLog) - a client and server middleware that logs requests and responses using SwiftLog.
30+
- [`RetryingClientMiddleware`](./RetryingClientMiddleware) - a client middleware that retries failed requests.
31+
2632
## Project and target types
2733

2834
The following examples show various ways that Swift OpenAPI Generator can be adopted from a consumer Swift package or an Xcode project.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.DS_Store
2+
.build
3+
/Packages
4+
/*.xcodeproj
5+
xcuserdata/
6+
DerivedData/
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.vscode
9+
/Package.resolved
10+
.ci/
11+
.docc-build/
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// swift-tools-version:5.9
2+
//===----------------------------------------------------------------------===//
3+
//
4+
// This source file is part of the SwiftOpenAPIGenerator open source project
5+
//
6+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
7+
// Licensed under Apache License v2.0
8+
//
9+
// See LICENSE.txt for license information
10+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
11+
//
12+
// SPDX-License-Identifier: Apache-2.0
13+
//
14+
//===----------------------------------------------------------------------===//
15+
import PackageDescription
16+
17+
let package = Package(
18+
name: "RetryingClientMiddleware",
19+
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .visionOS(.v1)],
20+
dependencies: [
21+
.package(url: "https://github.com/apple/swift-openapi-generator", exact: "1.0.0-alpha.1"),
22+
.package(url: "https://github.com/apple/swift-openapi-runtime", exact: "1.0.0-alpha.1"),
23+
.package(url: "https://github.com/apple/swift-openapi-urlsession", exact: "1.0.0-alpha.1"),
24+
.package(url: "https://github.com/apple/swift-http-types", from: "1.0.0"),
25+
],
26+
targets: [
27+
.target(
28+
name: "RetryingClientMiddleware",
29+
dependencies: [
30+
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
31+
.product(name: "HTTPTypes", package: "swift-http-types"),
32+
]
33+
),
34+
.executableTarget(
35+
name: "HelloWorldURLSessionClient",
36+
dependencies: [
37+
"RetryingClientMiddleware", .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
38+
.product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
39+
],
40+
plugins: [.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")]
41+
),
42+
]
43+
)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Client Retrying Middleware
2+
3+
In this example we'll implement a `ClientMiddleware` that retries certain failed responses again.
4+
5+
## Overview
6+
7+
This example extends the [HelloWorldURLSessionClient](../HelloWorldURLSessionClient)
8+
with a new target, `RetryingClientMiddleware`, which is then used when creating
9+
the `Client`.
10+
11+
Requests with a body are only retried if the request body has `iterationPolicy` of `multiple`, as otherwise
12+
the request body cannot be iterated again.
13+
14+
NOTE: This example shows just one way of retrying HTTP failures in a middleware
15+
and is purely for illustrative purposes.
16+
17+
The tool uses the [URLSession](https://developer.apple.com/documentation/foundation/urlsession) API to perform the HTTP call, wrapped in the [Swift OpenAPI URLSession Transport](https://github.com/apple/swift-openapi-urlsession).
18+
19+
The server can be started by running the any of the `HelloWorld*Server` examples locally.
20+
21+
## Usage
22+
23+
Build and run the client CLI using:
24+
25+
```
26+
$ swift run
27+
Attempt 1
28+
Retrying with code 500
29+
Attempt 2
30+
Returning the received response, either because of success or ran out of attempts.
31+
Hello, Stranger!
32+
```
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import OpenAPIURLSession
15+
import Foundation
16+
import RetryingClientMiddleware
17+
18+
@main struct HelloWorldURLSessionClient {
19+
static func main() async throws {
20+
let client = Client(
21+
serverURL: URL(string: "http://localhost:8080/api")!,
22+
transport: URLSessionTransport(),
23+
middlewares: [
24+
RetryingMiddleware(
25+
signals: [.code(429), .range(500..<600), .errorThrown],
26+
policy: .upToAttempts(count: 3),
27+
delay: .constant(seconds: 1)
28+
)
29+
]
30+
)
31+
let response = try await client.getGreeting()
32+
print(try response.ok.body.json.message)
33+
}
34+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
generate:
2+
- types
3+
- client
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
openapi: '3.1.0'
2+
info:
3+
title: GreetingService
4+
version: 1.0.0
5+
servers:
6+
- url: https://example.com/api
7+
description: Example service deployment.
8+
paths:
9+
/greet:
10+
get:
11+
operationId: getGreeting
12+
parameters:
13+
- name: name
14+
required: false
15+
in: query
16+
description: The name used in the returned greeting.
17+
schema:
18+
type: string
19+
responses:
20+
'200':
21+
description: A success response with a greeting.
22+
content:
23+
application/json:
24+
schema:
25+
$ref: '#/components/schemas/Greeting'
26+
components:
27+
schemas:
28+
Greeting:
29+
type: object
30+
description: A value with the greeting contents.
31+
properties:
32+
message:
33+
type: string
34+
description: The string representation of the greeting.
35+
required:
36+
- message
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import OpenAPIRuntime
15+
import Foundation
16+
import HTTPTypes
17+
18+
/// A middleware that retries the request under certain conditions.
19+
///
20+
/// Only meant to be used for illustrative purposes.
21+
package struct RetryingMiddleware {
22+
23+
/// The failure signal that can lead to a retried request.
24+
package enum RetryableSignal: Hashable {
25+
26+
/// Retry if the response code matches this code.
27+
case code(Int)
28+
29+
/// Retry if the response code falls into this range.
30+
case range(Range<Int>)
31+
32+
/// Retry if an error is thrown by a downstream middleware or transport.
33+
case errorThrown
34+
}
35+
36+
/// The policy to use when a retryable signal hints that a retry might be appropriate.
37+
package enum RetryingPolicy: Hashable {
38+
39+
/// Don't retry.
40+
case never
41+
42+
/// Retry up to the provided number of attempts.
43+
case upToAttempts(count: Int)
44+
}
45+
46+
/// The policy of delaying the retried request.
47+
package enum DelayPolicy: Hashable {
48+
49+
/// Don't delay, retry immediately.
50+
case none
51+
52+
/// Constant delay.
53+
case constant(seconds: TimeInterval)
54+
}
55+
56+
/// The signals that lead to the retry policy being evaluated.
57+
package var signals: Set<RetryableSignal>
58+
59+
/// The policy used to evaluate whether to perform a retry.
60+
package var policy: RetryingPolicy
61+
62+
/// The delay policy for retries.
63+
package var delay: DelayPolicy
64+
65+
/// Creates a new retrying middleware.
66+
/// - Parameters:
67+
/// - signals: The signals that lead to the retry policy being evaluated.
68+
/// - policy: The policy used to evaluate whether to perform a retry.
69+
/// - delay: The delay policy for retries.
70+
package init(
71+
signals: Set<RetryableSignal> = [.code(429), .range(500..<600), .errorThrown],
72+
policy: RetryingPolicy = .upToAttempts(count: 3),
73+
delay: DelayPolicy = .constant(seconds: 1)
74+
) {
75+
self.signals = signals
76+
self.policy = policy
77+
self.delay = delay
78+
}
79+
}
80+
81+
extension RetryingMiddleware: ClientMiddleware {
82+
package func intercept(
83+
_ request: HTTPRequest,
84+
body: HTTPBody?,
85+
baseURL: URL,
86+
operationID: String,
87+
next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
88+
) async throws -> (HTTPResponse, HTTPBody?) {
89+
guard case .upToAttempts(count: let maxAttemptCount) = policy else {
90+
return try await next(request, body, baseURL)
91+
}
92+
if let body { guard body.iterationBehavior == .multiple else { return try await next(request, body, baseURL) } }
93+
func willRetry() async throws {
94+
switch delay {
95+
case .none: return
96+
case .constant(seconds: let seconds): try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
97+
}
98+
}
99+
for attempt in 1...maxAttemptCount {
100+
print("Attempt \(attempt)")
101+
let (response, responseBody): (HTTPResponse, HTTPBody?)
102+
if signals.contains(.errorThrown) {
103+
do { (response, responseBody) = try await next(request, body, baseURL) } catch {
104+
if attempt == maxAttemptCount {
105+
throw error
106+
} else {
107+
print("Retrying after an error")
108+
try await willRetry()
109+
continue
110+
}
111+
}
112+
} else {
113+
(response, responseBody) = try await next(request, body, baseURL)
114+
}
115+
if signals.contains(response.status.code) && attempt < maxAttemptCount {
116+
print("Retrying with code \(response.status.code)")
117+
try await willRetry()
118+
continue
119+
} else {
120+
print("Returning the received response, either because of success or ran out of attempts.")
121+
return (response, responseBody)
122+
}
123+
}
124+
preconditionFailure("Unreachable")
125+
}
126+
}
127+
128+
extension Set where Element == RetryingMiddleware.RetryableSignal {
129+
/// Checks whether the provided response code matches the retryable signals.
130+
/// - Parameter code: The provided code to check.
131+
/// - Returns: `true` if the code matches at least one of the signals, `false` otherwise.
132+
func contains(_ code: Int) -> Bool {
133+
for signal in self {
134+
switch signal {
135+
case .code(let int): if code == int { return true }
136+
case .range(let range): if range.contains(code) { return true }
137+
case .errorThrown: break
138+
}
139+
}
140+
return false
141+
}
142+
}

0 commit comments

Comments
 (0)