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 <a_ramani@apple.com>
This commit is contained in:
Aditya Ramani
2025-06-16 11:07:32 -07:00
committed by GitHub
parent 77c54434b8
commit f4177e7d67
4 changed files with 54 additions and 10 deletions
@@ -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) ?? "{}"
}
}
}
@@ -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
@@ -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 {
@@ -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)
}
}
}