From f4177e7d67a0182272f75e5a1e85fd5a2c7d44ce Mon Sep 17 00:00:00 2001 From: Aditya Ramani Date: Mon, 16 Jun 2025 11:07:32 -0700 Subject: [PATCH] More informative errors from RegistryClient (#134) Also creates an `ErrorResponse` type to model the errors typically returned by a container registry. Reference: https://distribution.github.io/distribution/spec/api/#errors Example error message: ``` Error: HTTP request to https://ghcr.io/token?client_id=containerization-registry-client&service=ghcr.io&scope=repository:user/image:pull failed with response: 403 Forbidden. Reason: {"errors":[{"message":"requested access to the resource is denied","code":"DENIED"}]} ``` Signed-off-by: Aditya Ramani --- .../Client/RegistryClient+Error.swift | 43 +++++++++++++++++-- .../Client/RegistryClient+Fetch.swift | 6 ++- .../Client/RegistryClient+Push.swift | 9 ++-- .../Client/RegistryClient.swift | 6 ++- 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/Sources/ContainerizationOCI/Client/RegistryClient+Error.swift b/Sources/ContainerizationOCI/Client/RegistryClient+Error.swift index febeffa5..7539a562 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient+Error.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient+Error.swift @@ -14,19 +14,56 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import AsyncHTTPClient +import Foundation import NIOHTTP1 extension RegistryClient { /// `RegistryClient` errors. public enum Error: Swift.Error, CustomStringConvertible { - case invalidStatus(url: String, HTTPResponseStatus) + case invalidStatus(url: String, HTTPResponseStatus, reason: String? = nil) /// Description of the errors. public var description: String { switch self { - case .invalidStatus(let u, let response): - return "HTTP request to \(u) failed with response: \(response.description)" + case .invalidStatus(let u, let response, let reason): + return "HTTP request to \(u) failed with response: \(response.description). Reason: \(reason ?? "Unknown")" } } } + + /// The container registry typically returns actionable failure reasons in the response body + /// of the failing HTTP Request. This type models the structure of the error message. + /// Reference: https://distribution.github.io/distribution/spec/api/#errors + internal struct ErrorResponse: Codable { + let errors: [RemoteError] + + internal struct RemoteError: Codable { + let code: String + let message: String + let detail: String? + } + + internal static func fromResponseBody(_ body: HTTPClientResponse.Body) async -> ErrorResponse? { + guard var buffer = try? await body.collect(upTo: Int(1.mib())) else { + return nil + } + guard let bytes = buffer.readBytes(length: buffer.readableBytes) else { + return nil + } + let data = Data(bytes) + guard let jsonError = try? JSONDecoder().decode(ErrorResponse.self, from: data) else { + return nil + } + return jsonError + } + + public var jsonString: String { + let data = try? JSONEncoder().encode(self) + guard let data else { + return "{}" + } + return String(data: data, encoding: .utf8) ?? "{}" + } + } } diff --git a/Sources/ContainerizationOCI/Client/RegistryClient+Fetch.swift b/Sources/ContainerizationOCI/Client/RegistryClient+Fetch.swift index e9211d9e..644cfd60 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient+Fetch.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient+Fetch.swift @@ -49,7 +49,8 @@ extension RegistryClient { return try await request(components: components, method: .HEAD, headers: headers) { response in guard response.status == .ok else { let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } guard let digest = response.headers.first(name: "Docker-Content-Digest") else { @@ -150,7 +151,8 @@ extension RegistryClient { try await request(components: components, headers: headers) { response in guard response.status == .ok else { let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } // How many bytes to expect diff --git a/Sources/ContainerizationOCI/Client/RegistryClient+Push.swift b/Sources/ContainerizationOCI/Client/RegistryClient+Push.swift index 0135c810..2e7affa0 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient+Push.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient+Push.swift @@ -93,7 +93,8 @@ extension RegistryClient { } } else if response.status != .notFound { let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } } @@ -114,7 +115,8 @@ extension RegistryClient { throw ContainerizationError(.exists, message: "Content already exists \(descriptor.digest)") default: let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } // Get the location to upload the blob. @@ -149,7 +151,8 @@ extension RegistryClient { break default: let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } guard descriptor.digest == response.headers.first(name: "Docker-Content-Digest") else { diff --git a/Sources/ContainerizationOCI/Client/RegistryClient.swift b/Sources/ContainerizationOCI/Client/RegistryClient.swift index b0c89343..6c897e23 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient.swift @@ -235,7 +235,8 @@ public final class RegistryClient: ContentClient { try await request(components: components, method: .GET, headers: headers) { response in guard response.status == .ok else { let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } var body = try await response.body.collect(upTo: self.bufferSize) @@ -263,7 +264,8 @@ public final class RegistryClient: ContentClient { try await request(components: components) { response in guard response.status == .ok else { let url = components.url?.absoluteString ?? "unknown" - throw Error.invalidStatus(url: url, response.status) + let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString + throw Error.invalidStatus(url: url, response.status, reason: reason) } } }