From 9150d6dd49a5f4a8a6be5894e90c227c631b4e53 Mon Sep 17 00:00:00 2001 From: chingor13 Date: Mon, 17 Aug 2026 19:31:56 +0000 Subject: [PATCH 1/5] refactor: 503 errors are handled by the retry policy --- .../StorageClient+Upload.swift | 17 ++- packages/storage/Tests/ChecksumTests.swift | 28 +++-- .../storage/Tests/ResumableUploadTests.swift | 27 ++--- .../storage/Tests/SimpleUploadTests.swift | 108 +++++++++++++++++- 4 files changed, 138 insertions(+), 42 deletions(-) diff --git a/packages/storage/Sources/GoogleCloudStorage/StorageClient+Upload.swift b/packages/storage/Sources/GoogleCloudStorage/StorageClient+Upload.swift index 57a2cc029..752df5021 100644 --- a/packages/storage/Sources/GoogleCloudStorage/StorageClient+Upload.swift +++ b/packages/storage/Sources/GoogleCloudStorage/StorageClient+Upload.swift @@ -194,7 +194,7 @@ extension StorageClient { } catch { throw RequestError.io(error) } - if response.status == .serviceUnavailable { + if response.isError() { throw await response.decodeError() } let object = try await handleObjectResponse(response: response) @@ -225,7 +225,7 @@ extension StorageClient { guard statusCode == 200, let location = startResponse.headers.first(name: "Location") else { - if statusCode == 503 { + if startResponse.isError() { throw await startResponse.decodeError() } let startData = try await startResponse.data() @@ -257,7 +257,7 @@ extension StorageClient { } else if statusCode == 308 { let queryStatus = try parseResumableUploadQueryStatus(from: queryResponse.headers) return (.inprogress(UInt64(queryStatus.nextOffset)), queryStatus.crc32cSeed) - } else if statusCode == 503 { + } else if queryResponse.isError() { throw await queryResponse.decodeError() } else { let queryData = try await queryResponse.data() @@ -341,7 +341,7 @@ extension StorageClient { ) ) return (.inprogress(UInt64(nextOffset)), crc32cSeed) - } else if statusCode == 503 { + } else if uploadResponse.isError() { throw await uploadResponse.decodeError() } else { let uploadData = try await uploadResponse.data() @@ -827,13 +827,10 @@ extension StorageClient { async throws -> Object { - let statusCode = Int(response.status.code) - let data = try await response.data() - guard (200..<300).contains(statusCode) else { - let message = String(data: data, encoding: .utf8) ?? "" - throw UploadError.unexpectedServerResponse( - statusCode: statusCode, message: message) + if response.isError() { + throw await response.decodeError() } + let data = try await response.data() let decoder = GoogleCloudWkt._ProtoJSONDecoder() let v1Object = try decoder.decode(ObjectV1Response.self, from: data) return v1Object.toObject() diff --git a/packages/storage/Tests/ChecksumTests.swift b/packages/storage/Tests/ChecksumTests.swift index 9d3999ac4..c83ece957 100644 --- a/packages/storage/Tests/ChecksumTests.swift +++ b/packages/storage/Tests/ChecksumTests.swift @@ -100,12 +100,14 @@ import Testing let uploadOptions = UploadOptions().with { $0.validation = .crc32c } let task = client.upload(source, to: bucket, as: objectName, options: uploadOptions) - do { - _ = try await task.value - Issue.record("Expected upload to fail with unexpected server response") - } catch UploadError.unexpectedServerResponse(let statusCode, let message) { - #expect(statusCode == 400) - #expect(message == errorMessage) + let error = await expectError(RequestError.self) { + try await task.value + } + if case .http(let details) = error { + #expect(details.http_status_code == 400) + #expect(String(data: details.payload ?? Data(), encoding: .utf8) == errorMessage) + } else { + Issue.record("Expected .http RequestError, got \(String(describing: error))") } } @@ -144,12 +146,14 @@ import Testing let uploadOptions = UploadOptions().with { $0.validation = .md5 } let task = client.upload(source, to: bucket, as: objectName, options: uploadOptions) - do { - _ = try await task.value - Issue.record("Expected upload to fail with unexpected server response") - } catch UploadError.unexpectedServerResponse(let statusCode, let message) { - #expect(statusCode == 400) - #expect(message == errorMessage) + let error = await expectError(RequestError.self) { + try await task.value + } + if case .http(let details) = error { + #expect(details.http_status_code == 400) + #expect(String(data: details.payload ?? Data(), encoding: .utf8) == errorMessage) + } else { + Issue.record("Expected .http RequestError, got \(String(describing: error))") } } diff --git a/packages/storage/Tests/ResumableUploadTests.swift b/packages/storage/Tests/ResumableUploadTests.swift index 109141711..381c90165 100644 --- a/packages/storage/Tests/ResumableUploadTests.swift +++ b/packages/storage/Tests/ResumableUploadTests.swift @@ -173,14 +173,13 @@ import Testing let client = try makeClient(registry: registry) let task = client.upload(source, to: bucket, as: objectName) - let error = await expectUploadError { + let error = await expectError(RequestError.self) { try await task.value } - if case .unexpectedServerResponse(let statusCode, let message) = error { - #expect(statusCode == 500) - #expect(message == "Internal Server Error") + if case .http(let details) = error { + #expect(details.http_status_code == 500) } else { - Issue.record("Expected .unexpectedServerResponse, got \(String(describing: error))") + Issue.record("Expected .http RequestError, got \(String(describing: error))") } } @@ -437,14 +436,13 @@ import Testing let client = try makeClient(registry: registry) let task = client.resumeUpload(source, uploadId: queryUrl.absoluteString) - let error = await expectUploadError { + let error = await expectError(RequestError.self) { try await task.value } - if case .unexpectedServerResponse(let statusCode, let message) = error { - #expect(statusCode == 404) - #expect(message == "Upload session expired") + if case .http(let details) = error { + #expect(details.http_status_code == 404) } else { - Issue.record("Expected .unexpectedServerResponse, got \(error)") + Issue.record("Expected .http RequestError, got \(String(describing: error))") } } @@ -497,14 +495,13 @@ import Testing let client = try makeClient(registry: registry) let task = client.resumeUpload(source, uploadId: queryUrl.absoluteString) - let error = await expectUploadError { + let error = await expectError(RequestError.self) { try await task.value } - if case .unexpectedServerResponse(let statusCode, let message) = error { - #expect(statusCode == 499) - #expect(message == "Client Closed Request") + if case .http(let details) = error { + #expect(details.http_status_code == 499) } else { - Issue.record("Expected .unexpectedServerResponse, got \(String(describing: error))") + Issue.record("Expected .http RequestError, got \(String(describing: error))") } } diff --git a/packages/storage/Tests/SimpleUploadTests.swift b/packages/storage/Tests/SimpleUploadTests.swift index 1c927d891..017f10730 100644 --- a/packages/storage/Tests/SimpleUploadTests.swift +++ b/packages/storage/Tests/SimpleUploadTests.swift @@ -133,14 +133,13 @@ import Testing let client = try makeClient(registry: registry) let task = client.upload(source, to: bucket, as: objectName) - let error = await expectUploadError { + let error = await expectError(RequestError.self) { try await task.value } - if case .unexpectedServerResponse(let statusCode, let message) = error { - #expect(statusCode == 500) - #expect(message == "Internal Server Error") + if case .http(let details) = error { + #expect(details.http_status_code == 500) } else { - Issue.record("Expected .unexpectedServerResponse, got \(String(describing: error))") + Issue.record("Expected .http RequestError, got \(String(describing: error))") } } @@ -242,4 +241,103 @@ import Testing #expect( uploadReq?.value(forHTTPHeaderField: "x-goog-encryption-key-sha256") == sample.keyHashBase64) } + + /// Tests that a 503 (unavailable) response during simple upload is retryable and automatically handled by the retry loop. + @Test func simpleUploadTransientFailureRetriesAndSucceeds() async throws { + let registry = MockRegistry.create() + let bucket = "test-bucket" + let objectName = "test-simple-retry" + let data = Data(repeating: 0x42, count: 1024) + let source = BytesSource(data: data) + + let simpleUploadUrl = registry.url( + "/upload/storage/v1/b/\(bucket)/o?uploadType=multipart&name=\(objectName)") + + // 1. First attempt fails with 503 Service Unavailable + registry.register( + response: .success( + statusCode: 503, data: Data("Service Unavailable".utf8), + headers: nil), + for: simpleUploadUrl) + + // 2. Retry attempt succeeds with 200 OK + registry.register( + response: .success( + statusCode: 200, data: makeObjectJSON(name: objectName, bucket: bucket, size: data.count), + headers: nil), + for: simpleUploadUrl) + + let client = try makeClient( + registry: registry, + retryPolicy: BaseRetryPolicy().withAttemptLimit(3) + ) + let task = client.upload(source, to: bucket, as: objectName) + let object = try await task.value + + #expect(object.name == objectName) + #expect(object.bucket == bucket) + #expect(object.size == Int64(data.count)) + + let requests = registry.recordedRequests() + #expect(requests.count == 2) + } + + /// Tests that a 503 error with NeverRetry policy throws immediately without retrying. + @Test func simpleUploadTransientFailureWithNeverRetryFails() async throws { + let registry = MockRegistry.create() + let bucket = "test-bucket" + let objectName = "test-simple-never-retry" + let data = Data(repeating: 0x42, count: 1024) + let source = BytesSource(data: data) + + let simpleUploadUrl = registry.url( + "/upload/storage/v1/b/\(bucket)/o?uploadType=multipart&name=\(objectName)") + + registry.register( + response: .success( + statusCode: 503, data: Data("Service Unavailable".utf8), + headers: nil), + for: simpleUploadUrl) + + let client = try makeClient(registry: registry, retryPolicy: NeverRetry()) + let task = client.upload(source, to: bucket, as: objectName) + + let error = await expectError(RequestError.self) { + try await task.value + } + #expect(error != nil) + let requests = registry.recordedRequests() + #expect(requests.count == 1) + } + + /// Tests that configuring retryPolicy on UploadOptions overrides client-level retry policy for simple uploads. + @Test func simpleUploadWithCustomUploadOptionsRetryPolicyOverridesClient() async throws { + let registry = MockRegistry.create() + let bucket = "test-bucket" + let objectName = "test-simple-override-retry" + let data = Data(repeating: 0x42, count: 1024) + let source = BytesSource(data: data) + + let simpleUploadUrl = registry.url( + "/upload/storage/v1/b/\(bucket)/o?uploadType=multipart&name=\(objectName)") + + registry.register( + response: .success( + statusCode: 503, data: Data("Service Unavailable".utf8), + headers: nil), + for: simpleUploadUrl) + + let client = try makeClient(registry: registry) + let uploadOptions = UploadOptions().with { + $0.retryPolicy = NeverRetry() + } + let task = client.upload(source, to: bucket, as: objectName, options: uploadOptions) + + let error = await expectError(RequestError.self) { + try await task.value + } + #expect(error != nil) + let requests = registry.recordedRequests() + #expect(requests.count == 1) + } } From d1256cae9d3df54a77210247b1f1c31a60bba9e7 Mon Sep 17 00:00:00 2001 From: chingor13 Date: Mon, 17 Aug 2026 19:40:35 +0000 Subject: [PATCH 2/5] test: add tests for 503 recovery during resumable upload session --- .../storage/Tests/ResumableUploadTests.swift | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/packages/storage/Tests/ResumableUploadTests.swift b/packages/storage/Tests/ResumableUploadTests.swift index 381c90165..83b931602 100644 --- a/packages/storage/Tests/ResumableUploadTests.swift +++ b/packages/storage/Tests/ResumableUploadTests.swift @@ -1395,6 +1395,268 @@ import Testing #expect(requests[2].value(forHTTPHeaderField: "Content-Range") == "bytes 0-8388607/8388608") } + /// Tests that multiple 503 errors occurring across different chunks during a multi-chunk resumable upload + /// are each retried and recovered by `_RetryLoop`. + @Test func resumableUploadMultiple503ErrorsAcrossChunksRetriesAndRecovers() async throws { + let registry = MockRegistry.create() + let bucket = "test-bucket" + let objectName = "multi-503-chunk-recovery" + let chunkSize = 8 * 1024 * 1024 + let totalSize = 24 * 1024 * 1024 // 24MiB (3 chunks of 8MiB) + let data = Data(repeating: 0x77, count: totalSize) + let source = BytesSource(data: data) + + let startUrl = registry.url( + "/upload/storage/v1/b/\(bucket)/o?uploadType=resumable&name=\(objectName)") + let sessionUrl = registry.url("/upload/storage/v1/b/\(bucket)/o?upload_id=multi-503-session-id") + + // 1. Session start succeeds -> 200 OK with Location + registry.register( + response: .success( + statusCode: 200, data: Data(), + headers: ["Location": sessionUrl.absoluteString]), + for: startUrl) + + // 2. Chunk 1 (0..8MB) succeeds -> 308 Range 0-8MB + registry.register( + response: .success( + statusCode: 308, data: Data(), + headers: ["Range": "bytes=0-\(chunkSize - 1)"]), + for: sessionUrl) + + // 3. Chunk 2 (8MB..16MB) attempt 1 fails with 503 + registry.register( + response: .success( + statusCode: 503, data: Data("Service Unavailable".utf8), + headers: [:]), + for: sessionUrl) + + // 4. Status query for Chunk 2 recovery -> 308 Range 0-8MB + registry.register( + response: .success( + statusCode: 308, data: Data(), + headers: ["Range": "bytes=0-\(chunkSize - 1)"]), + for: sessionUrl) + + // 5. Chunk 2 (8MB..16MB) attempt 2 succeeds -> 308 Range 0-16MB + registry.register( + response: .success( + statusCode: 308, data: Data(), + headers: ["Range": "bytes=0-\(2 * chunkSize - 1)"]), + for: sessionUrl) + + // 6. Chunk 3 (16MB..24MB) attempt 1 fails with 503 + registry.register( + response: .success( + statusCode: 503, data: Data("Service Unavailable".utf8), + headers: [:]), + for: sessionUrl) + + // 7. Status query for Chunk 3 recovery -> 308 Range 0-16MB + registry.register( + response: .success( + statusCode: 308, data: Data(), + headers: ["Range": "bytes=0-\(2 * chunkSize - 1)"]), + for: sessionUrl) + + // 8. Chunk 3 (16MB..24MB) attempt 2 succeeds -> 200 OK + registry.register( + response: .success( + statusCode: 200, + data: makeObjectJSON(name: objectName, bucket: bucket, size: totalSize), + headers: nil), + for: sessionUrl) + + let client = try makeClient( + registry: registry, + clientRetryPolicy: BaseRetryPolicy().withAttemptLimit(5) + ) + let uploadOptions = UploadOptions().with { + $0.chunkSize = chunkSize + } + let task = client.upload(source, to: bucket, as: objectName, options: uploadOptions) + let object = try await task.value + + #expect(object.name == objectName) + #expect(object.bucket == bucket) + #expect(object.size == Int64(totalSize)) + + let requests = registry.recordedRequests() + #expect(requests.count == 8) + // 0: Start request + #expect(requests[0].httpMethod == "POST") + // 1: Chunk 1 (0-8MB) + #expect( + requests[1].value(forHTTPHeaderField: "Content-Range") + == "bytes 0-\(chunkSize - 1)/\(totalSize)") + // 2: Chunk 2 first attempt (8MB-16MB) - fails with 503 + #expect( + requests[2].value(forHTTPHeaderField: "Content-Range") + == "bytes \(chunkSize)-\(2 * chunkSize - 1)/\(totalSize)") + // 3: Status query after Chunk 2 transient failure + #expect(requests[3].value(forHTTPHeaderField: "Content-Range") == "bytes */*") + // 4: Chunk 2 second attempt (8MB-16MB) - succeeds + #expect( + requests[4].value(forHTTPHeaderField: "Content-Range") + == "bytes \(chunkSize)-\(2 * chunkSize - 1)/\(totalSize)") + // 5: Chunk 3 first attempt (16MB-24MB) - fails with 503 + #expect( + requests[5].value(forHTTPHeaderField: "Content-Range") + == "bytes \(2 * chunkSize)-\(totalSize - 1)/\(totalSize)") + // 6: Status query after Chunk 3 transient failure + #expect(requests[6].value(forHTTPHeaderField: "Content-Range") == "bytes */*") + // 7: Chunk 3 second attempt (16MB-24MB) - succeeds + #expect( + requests[7].value(forHTTPHeaderField: "Content-Range") + == "bytes \(2 * chunkSize)-\(totalSize - 1)/\(totalSize)") + } + + /// Tests that consecutive 503 errors on the same chunk upload are retried with backoff until succeeding. + @Test func resumableUploadConsecutive503ErrorsOnChunkRetriesAndRecovers() async throws { + let registry = MockRegistry.create() + let bucket = "test-bucket" + let objectName = "consecutive-503-recovery" + let data = Data(repeating: 0x88, count: 8 * 1024 * 1024) + let source = BytesSource(data: data) + + let startUrl = registry.url( + "/upload/storage/v1/b/\(bucket)/o?uploadType=resumable&name=\(objectName)") + let sessionUrl = registry.url( + "/upload/storage/v1/b/\(bucket)/o?upload_id=consecutive-session-id") + + // 1. Session start succeeds + registry.register( + response: .success( + statusCode: 200, data: Data(), + headers: ["Location": sessionUrl.absoluteString]), + for: startUrl) + + // 2. Chunk attempt 1 fails with 503 + registry.register( + response: .success( + statusCode: 503, data: Data("Service Unavailable 1".utf8), + headers: [:]), + for: sessionUrl) + + // 3. Status query 1 -> 308 (0 bytes committed) + registry.register( + response: .success( + statusCode: 308, data: Data(), + headers: [:]), + for: sessionUrl) + + // 4. Chunk attempt 2 fails with 503 + registry.register( + response: .success( + statusCode: 503, data: Data("Service Unavailable 2".utf8), + headers: [:]), + for: sessionUrl) + + // 5. Status query 2 -> 308 (0 bytes committed) + registry.register( + response: .success( + statusCode: 308, data: Data(), + headers: [:]), + for: sessionUrl) + + // 6. Chunk attempt 3 succeeds -> 200 OK + registry.register( + response: .success( + statusCode: 200, + data: makeObjectJSON(name: objectName, bucket: bucket, size: data.count), + headers: nil), + for: sessionUrl) + + let client = try makeClient( + registry: registry, + clientRetryPolicy: BaseRetryPolicy().withAttemptLimit(4) + ) + let task = client.upload(source, to: bucket, as: objectName) + let object = try await task.value + + #expect(object.name == objectName) + #expect(object.bucket == bucket) + #expect(object.size == Int64(data.count)) + + let requests = registry.recordedRequests() + #expect(requests.count == 6) + #expect(requests[0].httpMethod == "POST") + #expect(requests[1].value(forHTTPHeaderField: "Content-Range") == "bytes 0-8388607/8388608") + #expect(requests[2].value(forHTTPHeaderField: "Content-Range") == "bytes */*") + #expect(requests[3].value(forHTTPHeaderField: "Content-Range") == "bytes 0-8388607/8388608") + #expect(requests[4].value(forHTTPHeaderField: "Content-Range") == "bytes */*") + #expect(requests[5].value(forHTTPHeaderField: "Content-Range") == "bytes 0-8388607/8388608") + } + + /// Tests that a 503 error during status query recovery after a chunk 503 is also retried and succeeds. + @Test func resumableUpload503ErrorDuringStatusQueryRecovery() async throws { + let registry = MockRegistry.create() + let bucket = "test-bucket" + let objectName = "status-query-503-recovery" + let data = Data(repeating: 0x99, count: 8 * 1024 * 1024) + let source = BytesSource(data: data) + + let startUrl = registry.url( + "/upload/storage/v1/b/\(bucket)/o?uploadType=resumable&name=\(objectName)") + let sessionUrl = registry.url( + "/upload/storage/v1/b/\(bucket)/o?upload_id=status-query-503-session-id") + + // 1. Session start succeeds + registry.register( + response: .success( + statusCode: 200, data: Data(), + headers: ["Location": sessionUrl.absoluteString]), + for: startUrl) + + // 2. Chunk attempt fails with 503 + registry.register( + response: .success( + statusCode: 503, data: Data("Chunk Service Unavailable".utf8), + headers: [:]), + for: sessionUrl) + + // 3. Status query attempt 1 also fails with 503 + registry.register( + response: .success( + statusCode: 503, data: Data("Query Service Unavailable".utf8), + headers: [:]), + for: sessionUrl) + + // 4. Status query attempt 2 succeeds -> 308 (0 bytes committed) + registry.register( + response: .success( + statusCode: 308, data: Data(), + headers: [:]), + for: sessionUrl) + + // 5. Chunk retry attempt succeeds -> 200 OK + registry.register( + response: .success( + statusCode: 200, + data: makeObjectJSON(name: objectName, bucket: bucket, size: data.count), + headers: nil), + for: sessionUrl) + + let client = try makeClient( + registry: registry, + clientRetryPolicy: BaseRetryPolicy().withAttemptLimit(4) + ) + let task = client.upload(source, to: bucket, as: objectName) + let object = try await task.value + + #expect(object.name == objectName) + #expect(object.bucket == bucket) + #expect(object.size == Int64(data.count)) + + let requests = registry.recordedRequests() + #expect(requests.count == 5) + #expect(requests[0].httpMethod == "POST") + #expect(requests[1].value(forHTTPHeaderField: "Content-Range") == "bytes 0-8388607/8388608") + #expect(requests[2].value(forHTTPHeaderField: "Content-Range") == "bytes */*") + #expect(requests[3].value(forHTTPHeaderField: "Content-Range") == "bytes */*") + #expect(requests[4].value(forHTTPHeaderField: "Content-Range") == "bytes 0-8388607/8388608") + } + /// Tests that configuring `retryPolicy` on `UploadOptions` overrides client-level retry policy. @Test func resumableUploadWithCustomUploadOptionsRetryPolicyOverridesClient() async throws { let registry = MockRegistry.create() From 6d689a9f178b6fc1239ec00ddb4e7fedc7bfe1f4 Mon Sep 17 00:00:00 2001 From: chingor13 Date: Mon, 17 Aug 2026 19:52:40 +0000 Subject: [PATCH 3/5] test: add test for resuming where the server received partial data --- .../storage/Tests/ResumableUploadTests.swift | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/packages/storage/Tests/ResumableUploadTests.swift b/packages/storage/Tests/ResumableUploadTests.swift index 83b931602..de304c97d 100644 --- a/packages/storage/Tests/ResumableUploadTests.swift +++ b/packages/storage/Tests/ResumableUploadTests.swift @@ -1289,6 +1289,188 @@ import Testing requests[4].value(forHTTPHeaderField: "Content-Range") == "bytes 8388608-16777215/16777216") } + /// Tests that when a chunk fails with 503 and the status query indicates partial data was committed by GCS, + /// the upload automatically seeks and resumes from the latest committed byte. + @Test func resumableUploadPartialChunkCommittedOn503ResumesFromLatestByte() async throws { + let registry = MockRegistry.create() + let bucket = "test-bucket" + let objectName = "partial-chunk-recovery" + let chunkSize = 8 * 1024 * 1024 // 8MiB + let totalSize = 16 * 1024 * 1024 // 16MiB (2 chunks) + let partialCommitted = 12 * 1024 * 1024 // 12MiB (8MiB from chunk 1 + 4MiB from chunk 2) + let data = Data((0.. 200 OK with Location + registry.register( + response: .success( + statusCode: 200, data: Data(), + headers: ["Location": sessionUrl.absoluteString]), + for: startUrl) + + // 2. First chunk (0..8MB) succeeds -> 308 Range 0-8388607 + registry.register( + response: .success( + statusCode: 308, data: Data(), + headers: ["Range": "bytes=0-\(chunkSize - 1)"]), + for: sessionUrl) + + // 3. Second chunk (8MB..16MB) fails transiently with 503 + registry.register( + response: .success( + statusCode: 503, data: Data("Service Unavailable".utf8), + headers: [:]), + for: sessionUrl) + + // 4. Status query returns 308 showing 12MB committed (partial 4MB of chunk 2 was received by GCS before failure) + registry.register( + response: .success( + statusCode: 308, data: Data(), + headers: ["Range": "bytes=0-\(partialCommitted - 1)"]), + for: sessionUrl) + + // 5. Resumed upload from latest byte (12MB..16MB) succeeds -> 200 OK + registry.register( + response: .success( + statusCode: 200, + data: makeObjectJSON(name: objectName, bucket: bucket, size: totalSize), + headers: nil), + for: sessionUrl) + + let client = try makeClient( + registry: registry, + clientRetryPolicy: BaseRetryPolicy().withAttemptLimit(3) + ) + let uploadOptions = UploadOptions().with { + $0.chunkSize = chunkSize + } + let task = client.upload(source, to: bucket, as: objectName, options: uploadOptions) + + var statuses: [UploadStatus] = [] + for await status in task.makeStatusStream() { + statuses.append(status) + } + + let object = try await task.value + + #expect(object.name == objectName) + #expect(object.bucket == bucket) + #expect(object.size == Int64(totalSize)) + + let requests = registry.recordedRequests() + #expect(requests.count == 5) + // 0: Start request + #expect(requests[0].httpMethod == "POST") + // 1: Chunk 1 (0-8MB) + #expect( + requests[1].value(forHTTPHeaderField: "Content-Range") + == "bytes 0-\(chunkSize - 1)/\(totalSize)") + // 2: Chunk 2 first attempt (8MB-16MB) - fails with 503 + #expect( + requests[2].value(forHTTPHeaderField: "Content-Range") + == "bytes \(chunkSize)-\(totalSize - 1)/\(totalSize)") + // 3: Status query after transient failure + #expect(requests[3].value(forHTTPHeaderField: "Content-Range") == "bytes */*") + // 4: Resumed chunk starting from latest committed byte (12MB-16MB) + #expect( + requests[4].value(forHTTPHeaderField: "Content-Range") + == "bytes \(partialCommitted)-\(totalSize - 1)/\(totalSize)") + // Verify exact data payload sent in the resumed chunk + #expect(requests[4].httpBody == data.subdata(in: partialCommitted.. 200 OK + registry.register( + response: .success( + statusCode: 200, + data: makeObjectJSON(name: objectName, bucket: bucket, size: totalSize), + headers: nil), + for: sessionUrl) + + let client = try makeClient( + registry: registry, + clientRetryPolicy: BaseRetryPolicy().withAttemptLimit(3) + ) + let uploadOptions = UploadOptions().with { + $0.chunkSize = chunkSize + } + let task = client.upload(source, to: bucket, as: objectName, options: uploadOptions) + + var statuses: [UploadStatus] = [] + for await status in task.makeStatusStream() { + statuses.append(status) + } + + let object = try await task.value + + #expect(object.name == objectName) + #expect(object.bucket == bucket) + #expect(object.size == Int64(totalSize)) + + let requests = registry.recordedRequests() + #expect(requests.count == 4) + #expect(requests[0].httpMethod == "POST") + #expect( + requests[1].value(forHTTPHeaderField: "Content-Range") + == "bytes 0-\(chunkSize - 1)/\(totalSize)") + #expect(requests[2].value(forHTTPHeaderField: "Content-Range") == "bytes */*") + #expect( + requests[3].value(forHTTPHeaderField: "Content-Range") + == "bytes \(partialCommitted)-\(totalSize - 1)/\(totalSize)") + #expect(requests[3].httpBody == data.subdata(in: partialCommitted.. Date: Mon, 17 Aug 2026 20:57:26 +0000 Subject: [PATCH 4/5] fix test --- packages/storage/Tests/ChecksumTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/storage/Tests/ChecksumTests.swift b/packages/storage/Tests/ChecksumTests.swift index c83ece957..2a469ef12 100644 --- a/packages/storage/Tests/ChecksumTests.swift +++ b/packages/storage/Tests/ChecksumTests.swift @@ -105,7 +105,7 @@ import Testing } if case .http(let details) = error { #expect(details.http_status_code == 400) - #expect(String(data: details.payload ?? Data(), encoding: .utf8) == errorMessage) + #expect(String(data: details.payload, encoding: .utf8) == errorMessage) } else { Issue.record("Expected .http RequestError, got \(String(describing: error))") } @@ -151,7 +151,7 @@ import Testing } if case .http(let details) = error { #expect(details.http_status_code == 400) - #expect(String(data: details.payload ?? Data(), encoding: .utf8) == errorMessage) + #expect(String(data: details.payload, encoding: .utf8) == errorMessage) } else { Issue.record("Expected .http RequestError, got \(String(describing: error))") } From 4afb7089d71c259b90cd919e3b2a15da32767912 Mon Sep 17 00:00:00 2001 From: chingor13 Date: Mon, 17 Aug 2026 21:24:45 +0000 Subject: [PATCH 5/5] fix test --- .../StorageClientIntegrationTests.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/storage/Tests/IntegrationTests/StorageClientIntegrationTests.swift b/packages/storage/Tests/IntegrationTests/StorageClientIntegrationTests.swift index d169ee453..fed3f37fb 100644 --- a/packages/storage/Tests/IntegrationTests/StorageClientIntegrationTests.swift +++ b/packages/storage/Tests/IntegrationTests/StorageClientIntegrationTests.swift @@ -13,6 +13,7 @@ // limitations under the License. import Foundation +import GoogleCloudGax @testable import GoogleCloudStorage import Testing @@ -482,11 +483,16 @@ import Testing do { _ = try await task.value Issue.record("Expected GCS to reject upload with bad checksum, but it succeeded") - } catch UploadError.unexpectedServerResponse(let statusCode, let message) { - #expect(statusCode == 400) - print("GCS correctly rejected bad checksum: \(message)") + } catch RequestError.service(let serviceError) { + #expect(serviceError.message.contains("doesn't match")) + print("GCS correctly rejected bad checksum: \(serviceError.message)") + } catch RequestError.http(let details) { + #expect(details.http_status_code == 400) + print( + "GCS correctly rejected bad checksum: \(String(data: details.payload, encoding: .utf8) ?? "")" + ) } catch { - Issue.record("Expected UploadError.unexpectedServerResponse, but got \(error)") + Issue.record("Expected RequestError, but got \(error)") } }