Skip to content

Commit abd012b

Browse files
authored
fix: Retry handling for streamed request bodies (#852)
* fix: Rewind a stream before retrying it, don't retry nonseekable streams * Fix tests so they run without delay * Fix request count logic to include 1st request
1 parent 2519d38 commit abd012b

File tree

2 files changed

+91
-5
lines changed

2 files changed

+91
-5
lines changed

Diff for: Sources/ClientRuntime/Orchestrator/Orchestrator.swift

+26
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,13 @@ public struct Orchestrator<
265265
// If we can't get errorInfo, we definitely can't retry
266266
guard let errorInfo = retryErrorInfoProvider(error) else { return }
267267

268+
// If the body is a nonseekable stream, we also can't retry
269+
do {
270+
guard try readyBodyForRetry(request: copiedRequest) else { return }
271+
} catch {
272+
return
273+
}
274+
268275
// When refreshing fails it throws, indicating we're done retrying
269276
do {
270277
try await strategy.refreshRetryTokenForRetry(tokenToRenew: token, errorInfo: errorInfo)
@@ -277,6 +284,25 @@ public struct Orchestrator<
277284
}
278285
}
279286

287+
/// Readies the body for retry, and indicates whether the request body may be safely used in a retry.
288+
/// - Parameter request: The request to be retried.
289+
/// - Returns: `true` if the body of the request is safe to retry, `false` otherwise. In general, a request body is retriable if it is not a stream, or
290+
/// if the stream is seekable and successfully seeks to the start position / offset zero.
291+
private func readyBodyForRetry(request: RequestType) throws -> Bool {
292+
switch request.body {
293+
case .stream(let stream):
294+
guard stream.isSeekable else { return false }
295+
do {
296+
try stream.seek(toOffset: 0)
297+
return true
298+
} catch {
299+
return false
300+
}
301+
case .data, .noStream:
302+
return true
303+
}
304+
}
305+
280306
private func attempt(context: InterceptorContextType, attemptCount: Int) async {
281307
// If anything in here fails, the attempt short-circuits and we go to modifyBeforeAttemptCompletion,
282308
// with the thrown error in context.result

Diff for: Tests/ClientRuntimeTests/OrchestratorTests/OrchestratorTests.swift

+65-5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import SmithyRetriesAPI
1515
import SmithyRetries
1616
@_spi(SmithyReadWrite) import SmithyJSON
1717
@_spi(SmithyReadWrite) import SmithyReadWrite
18+
import SmithyStreams
1819

1920
class OrchestratorTests: XCTestCase {
2021
struct TestInput {
@@ -167,20 +168,23 @@ class OrchestratorTests: XCTestCase {
167168
}
168169

169170
class TraceExecuteRequest: ExecuteRequest {
170-
var succeedAfter: Int
171+
let succeedAfter: Int
171172
var trace: Trace
172173

174+
private(set) var requestCount = 0
175+
173176
init(succeedAfter: Int = 0, trace: Trace) {
174177
self.succeedAfter = succeedAfter
175178
self.trace = trace
176179
}
177180

178181
public func execute(request: HTTPRequest, attributes: Context) async throws -> HTTPResponse {
179182
trace.append("executeRequest")
180-
if succeedAfter <= 0 {
183+
if succeedAfter - requestCount <= 0 {
184+
requestCount += 1
181185
return HTTPResponse(body: request.body, statusCode: .ok)
182186
} else {
183-
succeedAfter -= 1
187+
requestCount += 1
184188
return HTTPResponse(body: request.body, statusCode: .internalServerError)
185189
}
186190
}
@@ -233,7 +237,7 @@ class OrchestratorTests: XCTestCase {
233237
throw try UnknownHTTPServiceError.makeError(baseError: baseError)
234238
}
235239
})
236-
.retryStrategy(DefaultRetryStrategy(options: RetryStrategyOptions(backoffStrategy: ExponentialBackoffStrategy())))
240+
.retryStrategy(DefaultRetryStrategy(options: RetryStrategyOptions(backoffStrategy: ImmediateBackoffStrategy())))
237241
.retryErrorInfoProvider({ e in
238242
trace.append("errorInfo")
239243
return DefaultRetryErrorInfoProvider.errorInfo(for: e)
@@ -530,7 +534,7 @@ class OrchestratorTests: XCTestCase {
530534
let initialTokenTrace = Trace()
531535
let initialToken = await asyncResult {
532536
return try await self.traceOrchestrator(trace: initialTokenTrace)
533-
.retryStrategy(ThrowingRetryStrategy(options: RetryStrategyOptions(backoffStrategy: ExponentialBackoffStrategy())))
537+
.retryStrategy(ThrowingRetryStrategy(options: RetryStrategyOptions(backoffStrategy: ImmediateBackoffStrategy())))
534538
.build()
535539
.execute(input: TestInput(foo: ""))
536540
}
@@ -1315,4 +1319,60 @@ class OrchestratorTests: XCTestCase {
13151319
}
13161320
}
13171321
}
1322+
1323+
/// Used in retry tests to perform the next retry without waiting, so that tests complete without delay.
1324+
private struct ImmediateBackoffStrategy: RetryBackoffStrategy {
1325+
func computeNextBackoffDelay(attempt: Int) -> TimeInterval { 0.0 }
1326+
}
1327+
1328+
func test_retry_retriesDataBody() async throws {
1329+
let input = TestInput(foo: "bar")
1330+
let trace = Trace()
1331+
let executeRequest = TraceExecuteRequest(succeedAfter: 2, trace: trace)
1332+
let orchestrator = traceOrchestrator(trace: trace)
1333+
.retryStrategy(DefaultRetryStrategy(options: RetryStrategyOptions(backoffStrategy: ImmediateBackoffStrategy())))
1334+
.serialize({ (input: TestInput, builder: HTTPRequestBuilder, context) in
1335+
builder.withBody(.data(Data("\"\(input.foo)\"".utf8)))
1336+
})
1337+
.executeRequest(executeRequest)
1338+
let result = await asyncResult {
1339+
return try await orchestrator.build().execute(input: input)
1340+
}
1341+
XCTAssertNoThrow(try result.get())
1342+
XCTAssertEqual(executeRequest.requestCount, 3)
1343+
}
1344+
1345+
func test_retry_doesntRetryNonSeekableStreamBody() async throws {
1346+
let input = TestInput(foo: "bar")
1347+
let trace = Trace()
1348+
let executeRequest = TraceExecuteRequest(succeedAfter: 2, trace: trace)
1349+
let orchestrator = traceOrchestrator(trace: trace)
1350+
.retryStrategy(DefaultRetryStrategy(options: RetryStrategyOptions(backoffStrategy: ImmediateBackoffStrategy())))
1351+
.serialize({ (input: TestInput, builder: HTTPRequestBuilder, context) in
1352+
builder.withBody(.stream(BufferedStream(data: Data("\"\(input.foo)\"".utf8), isClosed: true)))
1353+
})
1354+
.executeRequest(executeRequest)
1355+
let result = await asyncResult {
1356+
return try await orchestrator.build().execute(input: input)
1357+
}
1358+
XCTAssertThrowsError(try result.get())
1359+
XCTAssertEqual(executeRequest.requestCount, 1)
1360+
}
1361+
1362+
func test_retry_nonSeekableStreamBodySucceeds() async throws {
1363+
let input = TestInput(foo: "bar")
1364+
let trace = Trace()
1365+
let executeRequest = TraceExecuteRequest(succeedAfter: 0, trace: trace)
1366+
let orchestrator = traceOrchestrator(trace: trace)
1367+
.retryStrategy(DefaultRetryStrategy(options: RetryStrategyOptions(backoffStrategy: ImmediateBackoffStrategy())))
1368+
.serialize({ (input: TestInput, builder: HTTPRequestBuilder, context) in
1369+
builder.withBody(.stream(BufferedStream(data: Data("\"\(input.foo)\"".utf8), isClosed: true)))
1370+
})
1371+
.executeRequest(executeRequest)
1372+
let result = await asyncResult {
1373+
return try await orchestrator.build().execute(input: input)
1374+
}
1375+
XCTAssertNoThrow(try result.get())
1376+
XCTAssertEqual(executeRequest.requestCount, 1)
1377+
}
13181378
}

0 commit comments

Comments
 (0)