Add detailed diagnostics reporting to GIF keyboard failures
Changelog-Added: Added detailed diagnostics reporting to GIF keyboard errors Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
@@ -19,11 +19,18 @@ enum HTTPPayloadType: String {
|
|||||||
case binary = "application/octet-stream"
|
case binary = "application/octet-stream"
|
||||||
}
|
}
|
||||||
|
|
||||||
func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?, auth_keypair: Keypair) async throws -> (data: Data, response: URLResponse) {
|
/// Creates a NIP-98 authentication event for HTTP requests.
|
||||||
var request = URLRequest(url: url)
|
///
|
||||||
request.httpMethod = method.rawValue
|
/// This generates the Nostr event used for authenticating HTTP requests according to NIP-98.
|
||||||
request.httpBody = payload
|
/// The event includes the request URL, HTTP method, and optionally a payload hash.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - method: The HTTP method (GET, POST, PUT, DELETE)
|
||||||
|
/// - url: The full URL being requested
|
||||||
|
/// - payload: Optional request body data to hash and include in the auth event
|
||||||
|
/// - auth_keypair: The keypair to sign the auth event with
|
||||||
|
/// - Returns: The NIP-98 authentication event, or nil if event creation fails
|
||||||
|
func create_nip98_auth_event(method: HTTPMethod, url: URL, payload: Data?, auth_keypair: Keypair) -> NdbNote? {
|
||||||
var tag_pairs = [
|
var tag_pairs = [
|
||||||
["u", url.absoluteString],
|
["u", url.absoluteString],
|
||||||
["method", method.rawValue],
|
["method", method.rawValue],
|
||||||
@@ -35,14 +42,32 @@ func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Dat
|
|||||||
tag_pairs.append(["payload", payload_hash_hex])
|
tag_pairs.append(["payload", payload_hash_hex])
|
||||||
}
|
}
|
||||||
|
|
||||||
let auth_note = NdbNote(
|
return NdbNote(
|
||||||
content: "",
|
content: "",
|
||||||
keypair: auth_keypair,
|
keypair: auth_keypair,
|
||||||
kind: 27235,
|
kind: 27235,
|
||||||
tags: tag_pairs,
|
tags: tag_pairs,
|
||||||
createdAt: UInt32(Date().timeIntervalSince1970)
|
createdAt: UInt32(Date().timeIntervalSince1970)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes an HTTP request authenticated with a pre-built NIP-98 event.
|
||||||
|
///
|
||||||
|
/// This overload accepts a pre-built and signed NIP-98 authentication event.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - method: The HTTP method (GET, POST, PUT, DELETE)
|
||||||
|
/// - url: The full URL to request
|
||||||
|
/// - payload: Optional request body data
|
||||||
|
/// - payload_type: Optional Content-Type for the payload
|
||||||
|
/// - auth_note: The pre-built NIP-98 authentication event
|
||||||
|
/// - Returns: A tuple containing the response data and URLResponse
|
||||||
|
/// - Throws: Errors from URL loading or JSON encoding
|
||||||
|
func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?, auth_note: NdbNote) async throws -> (data: Data, response: URLResponse) {
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = method.rawValue
|
||||||
|
request.httpBody = payload
|
||||||
|
|
||||||
let auth_note_json_data: Data = try encode_json_data(auth_note)
|
let auth_note_json_data: Data = try encode_json_data(auth_note)
|
||||||
let auth_note_base64: String = base64_encode(auth_note_json_data.bytes)
|
let auth_note_base64: String = base64_encode(auth_note_json_data.bytes)
|
||||||
|
|
||||||
@@ -52,3 +77,24 @@ func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Dat
|
|||||||
}
|
}
|
||||||
return try await URLSession.shared.data(for: request)
|
return try await URLSession.shared.data(for: request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Makes an HTTP request authenticated with NIP-98.
|
||||||
|
///
|
||||||
|
/// This function creates a NIP-98 authentication event and includes it in the request's
|
||||||
|
/// Authorization header as a base64-encoded Nostr event.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - method: The HTTP method (GET, POST, PUT, DELETE)
|
||||||
|
/// - url: The full URL to request
|
||||||
|
/// - payload: Optional request body data
|
||||||
|
/// - payload_type: Optional Content-Type for the payload
|
||||||
|
/// - auth_keypair: The keypair to sign the auth event with
|
||||||
|
/// - Returns: A tuple containing the response data and URLResponse
|
||||||
|
/// - Throws: Errors from URL loading or JSON encoding
|
||||||
|
func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?, auth_keypair: Keypair) async throws -> (data: Data, response: URLResponse) {
|
||||||
|
guard let auth_note = create_nip98_auth_event(method: method, url: url, payload: payload, auth_keypair: auth_keypair) else {
|
||||||
|
throw URLError(.unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await make_nip98_authenticated_request(method: method, url: url, payload: payload, payload_type: payload_type, auth_note: auth_note)
|
||||||
|
}
|
||||||
|
|||||||
@@ -347,44 +347,71 @@ private extension PurpleGIFAPIError {
|
|||||||
/// Converts Purple GIF API failures into a reusable user-presentable error.
|
/// Converts Purple GIF API failures into a reusable user-presentable error.
|
||||||
func userPresentableError(action: String, currentQuery: String?) -> ErrorView.UserPresentableError {
|
func userPresentableError(action: String, currentQuery: String?) -> ErrorView.UserPresentableError {
|
||||||
let queryContext = currentQuery.map { "query=\($0)" } ?? "featured"
|
let queryContext = currentQuery.map { "query=\($0)" } ?? "featured"
|
||||||
|
|
||||||
|
// Build comprehensive technical info including error context
|
||||||
|
let baseTechnicalInfo = "GIF picker error while \(action); context=\(queryContext)"
|
||||||
|
let fullTechnicalInfo: String
|
||||||
|
|
||||||
|
if let errorContext = self.context {
|
||||||
|
fullTechnicalInfo = "\(baseTechnicalInfo); \(errorContext.debugDescription)"
|
||||||
|
} else {
|
||||||
|
fullTechnicalInfo = baseTechnicalInfo
|
||||||
|
}
|
||||||
|
|
||||||
switch self {
|
switch self {
|
||||||
case .unauthorized:
|
case .unauthorized(let context):
|
||||||
return .init(
|
// For 401 errors, show the server's error message with context
|
||||||
user_visible_description: NSLocalizedString("You need an active Purple subscription to use GIF search.", comment: "Error shown when the user is not authorized to use the GIF picker."),
|
let serverMessage = context?.extractedMessage
|
||||||
tip: NSLocalizedString("Make sure you're signed in with the right account and that your Purple subscription is active, then try again.", comment: "Advice shown when GIF picker access is denied."),
|
|
||||||
technical_info: "GIF picker unauthorized while \(action); context=\(queryContext)"
|
if let message = serverMessage, !message.isEmpty {
|
||||||
)
|
// Quote and provide context around the server message
|
||||||
|
let contextualizedMessage = String(format: NSLocalizedString("Access to the GIF service was denied by the server with the following message: \"%@\"", comment: "Error message format that quotes the server's error message"), message)
|
||||||
|
return .init(
|
||||||
|
user_visible_description: contextualizedMessage,
|
||||||
|
tip: NSLocalizedString("If the problem persists, copy the technical information and send it to support.", comment: "Advice shown when GIF picker access is denied with server message."),
|
||||||
|
technical_info: fullTechnicalInfo
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Fallback to generic message if no server message available
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("Access to the GIF service was denied.", comment: "Generic error shown when the user is not authorized to use the GIF picker and no server message is available."),
|
||||||
|
tip: NSLocalizedString("This could be due to an expired subscription, authentication issue, or network problem. Copy the technical information and send it to support for help.", comment: "Advice shown when GIF picker access is denied without specific details."),
|
||||||
|
technical_info: fullTechnicalInfo
|
||||||
|
)
|
||||||
|
}
|
||||||
case .invalidURL:
|
case .invalidURL:
|
||||||
return .init(
|
return .init(
|
||||||
user_visible_description: NSLocalizedString("The GIF service is misconfigured.", comment: "Error shown when the GIF picker generated an invalid URL."),
|
user_visible_description: NSLocalizedString("The GIF service is misconfigured.", comment: "Error shown when the GIF picker generated an invalid URL."),
|
||||||
tip: NSLocalizedString("Try again later. If this keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the GIF picker URL is invalid."),
|
tip: NSLocalizedString("Try again later. If this keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the GIF picker URL is invalid."),
|
||||||
technical_info: "GIF picker invalid URL while \(action); context=\(queryContext)"
|
technical_info: fullTechnicalInfo
|
||||||
)
|
)
|
||||||
case .invalidResponse:
|
case .invalidResponse:
|
||||||
return .init(
|
return .init(
|
||||||
user_visible_description: NSLocalizedString("The GIF service returned an unexpected response.", comment: "Error shown when the GIF picker receives an invalid server response."),
|
user_visible_description: NSLocalizedString("The GIF service returned an unexpected response.", comment: "Error shown when the GIF picker receives an invalid server response."),
|
||||||
tip: NSLocalizedString("Try again in a moment. If it keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the GIF picker receives an invalid server response."),
|
tip: NSLocalizedString("Try again in a moment. If it keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the GIF picker receives an invalid server response."),
|
||||||
technical_info: "GIF picker invalid response while \(action); context=\(queryContext)"
|
technical_info: fullTechnicalInfo
|
||||||
)
|
)
|
||||||
case .decodingError(let decodingError, let rawResponse):
|
case .decodingError(let decodingError, let rawResponse, _):
|
||||||
|
// Include decoding error details for legacy compatibility
|
||||||
let responseText = rawResponse ?? "<unavailable>"
|
let responseText = rawResponse ?? "<unavailable>"
|
||||||
|
let detailedInfo = "\(baseTechnicalInfo); error=\(String(describing: decodingError)); response=\(responseText)"
|
||||||
|
let withContext = self.context.map { "; \($0.debugDescription)" } ?? ""
|
||||||
return .init(
|
return .init(
|
||||||
user_visible_description: NSLocalizedString("We couldn't understand the GIF data from the server.", comment: "Error shown when GIF response parsing fails."),
|
user_visible_description: NSLocalizedString("We couldn't understand the GIF data from the server.", comment: "Error shown when GIF response parsing fails."),
|
||||||
tip: NSLocalizedString("Try again in a moment. If the problem continues, copy the technical information and send it to support.", comment: "Advice shown when GIF response parsing fails."),
|
tip: NSLocalizedString("Try again in a moment. If the problem continues, copy the technical information and send it to support.", comment: "Advice shown when GIF response parsing fails."),
|
||||||
technical_info: "GIF picker decoding error while \(action); context=\(queryContext); error=\(String(describing: decodingError)); response=\(responseText)"
|
technical_info: detailedInfo + withContext
|
||||||
)
|
)
|
||||||
case .networkError(let networkError):
|
case .networkError(let networkError, _):
|
||||||
return .init(
|
return .init(
|
||||||
user_visible_description: NSLocalizedString("We couldn't reach the GIF service.", comment: "Error shown when GIF loading fails because of a network issue."),
|
user_visible_description: NSLocalizedString("We couldn't reach the GIF service.", comment: "Error shown when GIF loading fails because of a network issue."),
|
||||||
tip: NSLocalizedString("Check your internet connection and try again.", comment: "Advice shown when GIF loading fails because of a network issue."),
|
tip: NSLocalizedString("Check your internet connection and try again.", comment: "Advice shown when GIF loading fails because of a network issue."),
|
||||||
technical_info: "GIF picker network error while \(action); context=\(queryContext); error=\(String(describing: networkError))"
|
technical_info: "\(baseTechnicalInfo); error=\(String(describing: networkError))" + (self.context.map { "; \($0.debugDescription)" } ?? "")
|
||||||
)
|
)
|
||||||
case .upstreamError(let statusCode, let message):
|
case .upstreamError(let statusCode, let message, _):
|
||||||
return .init(
|
return .init(
|
||||||
user_visible_description: NSLocalizedString("The GIF service is temporarily unavailable.", comment: "Error shown when the upstream GIF service fails."),
|
user_visible_description: NSLocalizedString("The GIF service is temporarily unavailable.", comment: "Error shown when the upstream GIF service fails."),
|
||||||
tip: NSLocalizedString("Try again in a moment. If the problem keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the upstream GIF service fails."),
|
tip: NSLocalizedString("Try again in a moment. If the problem keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the upstream GIF service fails."),
|
||||||
technical_info: "GIF picker upstream error while \(action); context=\(queryContext); status=\(statusCode); message=\(message ?? "none")"
|
technical_info: "\(baseTechnicalInfo); status=\(statusCode); message=\(message ?? "none")" + (self.context.map { "; \($0.debugDescription)" } ?? "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,42 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Sentry
|
||||||
|
|
||||||
|
/// Detailed context for GIF API errors, used for diagnostics and error reporting.
|
||||||
|
struct GIFAPIErrorContext {
|
||||||
|
/// The full HTTP response body from the server
|
||||||
|
let serverResponse: String?
|
||||||
|
/// HTTP status code returned by the server
|
||||||
|
let statusCode: Int?
|
||||||
|
/// The URL that was requested
|
||||||
|
let requestURL: String?
|
||||||
|
/// The NIP-98 authentication event that was sent (without sensitive private key data)
|
||||||
|
let nip98Event: String?
|
||||||
|
/// The timestamp when the request was made
|
||||||
|
let timestamp: Date
|
||||||
|
/// Extracted error message from server response (from "error" or "message" JSON key)
|
||||||
|
let extractedMessage: String?
|
||||||
|
|
||||||
|
/// Returns a formatted string suitable for logging and user-facing technical info
|
||||||
|
var debugDescription: String {
|
||||||
|
var parts: [String] = []
|
||||||
|
parts.append("timestamp=\(timestamp.ISO8601Format())")
|
||||||
|
if let url = requestURL { parts.append("url=\(url)") }
|
||||||
|
if let status = statusCode { parts.append("status=\(status)") }
|
||||||
|
if let response = serverResponse { parts.append("server_response=\(response)") }
|
||||||
|
if let event = nip98Event { parts.append("nip98_event=\(event)") }
|
||||||
|
return parts.joined(separator: "; ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum PurpleGIFAPIError: Error, LocalizedError {
|
enum PurpleGIFAPIError: Error, LocalizedError {
|
||||||
case invalidURL
|
case invalidURL
|
||||||
case invalidResponse
|
case invalidResponse(context: GIFAPIErrorContext?)
|
||||||
case unauthorized
|
case unauthorized(context: GIFAPIErrorContext?)
|
||||||
case upstreamError(statusCode: Int, message: String?)
|
case upstreamError(statusCode: Int, message: String?, context: GIFAPIErrorContext?)
|
||||||
case networkError(Error)
|
case networkError(Error, context: GIFAPIErrorContext?)
|
||||||
case decodingError(Error, rawResponse: String?)
|
case decodingError(Error, rawResponse: String?, context: GIFAPIErrorContext?)
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -21,14 +49,28 @@ enum PurpleGIFAPIError: Error, LocalizedError {
|
|||||||
return NSLocalizedString("Invalid response from GIF service", comment: "Error message for invalid Purple GIF response")
|
return NSLocalizedString("Invalid response from GIF service", comment: "Error message for invalid Purple GIF response")
|
||||||
case .unauthorized:
|
case .unauthorized:
|
||||||
return NSLocalizedString("Purple subscription required to use GIF search", comment: "Error message shown when the user is not authorized to use Purple GIF endpoints")
|
return NSLocalizedString("Purple subscription required to use GIF search", comment: "Error message shown when the user is not authorized to use Purple GIF endpoints")
|
||||||
case .upstreamError(_, let message):
|
case .upstreamError(_, let message, _):
|
||||||
return message ?? NSLocalizedString("GIF service is temporarily unavailable", comment: "Error message shown when the Purple GIF service fails")
|
return message ?? NSLocalizedString("GIF service is temporarily unavailable", comment: "Error message shown when the Purple GIF service fails")
|
||||||
case .networkError(let error):
|
case .networkError(let error, _):
|
||||||
return error.localizedDescription
|
return error.localizedDescription
|
||||||
case .decodingError:
|
case .decodingError:
|
||||||
return NSLocalizedString("Failed to parse GIF data", comment: "Error message for GIF decoding failure")
|
return NSLocalizedString("Failed to parse GIF data", comment: "Error message for GIF decoding failure")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the error context for diagnostic purposes
|
||||||
|
var context: GIFAPIErrorContext? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL:
|
||||||
|
return nil
|
||||||
|
case .invalidResponse(let context),
|
||||||
|
.unauthorized(let context),
|
||||||
|
.upstreamError(_, _, let context),
|
||||||
|
.networkError(_, let context),
|
||||||
|
.decodingError(_, _, let context):
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actor PurpleGIFAPIClient {
|
actor PurpleGIFAPIClient {
|
||||||
@@ -71,44 +113,116 @@ actor PurpleGIFAPIClient {
|
|||||||
|
|
||||||
/// Performs an authenticated request against the Purple GIF proxy.
|
/// Performs an authenticated request against the Purple GIF proxy.
|
||||||
private func performRequest(url: URL) async throws -> GIFSearchResponse {
|
private func performRequest(url: URL) async throws -> GIFSearchResponse {
|
||||||
|
let requestTimestamp = Date()
|
||||||
|
|
||||||
|
// Create NIP-98 event once and reuse it for both the request and error context
|
||||||
|
guard let nip98Event = create_nip98_auth_event(
|
||||||
|
method: .get,
|
||||||
|
url: url,
|
||||||
|
payload: nil,
|
||||||
|
auth_keypair: purple.keypair
|
||||||
|
) else {
|
||||||
|
throw PurpleGIFAPIError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
let nip98EventJSON: String?
|
||||||
|
if let data = try? encode_json_data(nip98Event) {
|
||||||
|
nip98EventJSON = String(data: data, encoding: .utf8)
|
||||||
|
} else {
|
||||||
|
nip98EventJSON = nil
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
// Use the pre-built event to ensure the actual auth event sent matches what we log
|
||||||
let (data, response) = try await make_nip98_authenticated_request(
|
let (data, response) = try await make_nip98_authenticated_request(
|
||||||
method: .get,
|
method: .get,
|
||||||
url: url,
|
url: url,
|
||||||
payload: nil,
|
payload: nil,
|
||||||
payload_type: nil,
|
payload_type: nil,
|
||||||
auth_keypair: purple.keypair
|
auth_note: nip98Event
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
throw PurpleGIFAPIError.invalidResponse
|
let context = GIFAPIErrorContext(
|
||||||
|
serverResponse: String(data: data, encoding: .utf8),
|
||||||
|
statusCode: nil,
|
||||||
|
requestURL: url.absoluteString,
|
||||||
|
nip98Event: nip98EventJSON,
|
||||||
|
timestamp: requestTimestamp,
|
||||||
|
extractedMessage: extractErrorMessage(from: data)
|
||||||
|
)
|
||||||
|
let error = PurpleGIFAPIError.invalidResponse(context: context)
|
||||||
|
reportErrorToSentry(error, context: context)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
guard (200...299).contains(httpResponse.statusCode) else {
|
guard (200...299).contains(httpResponse.statusCode) else {
|
||||||
throw mapHTTPError(statusCode: httpResponse.statusCode, data: data)
|
throw mapHTTPError(
|
||||||
|
statusCode: httpResponse.statusCode,
|
||||||
|
data: data,
|
||||||
|
url: url,
|
||||||
|
nip98Event: nip98EventJSON,
|
||||||
|
timestamp: requestTimestamp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
return try decoder.decode(GIFSearchResponse.self, from: data)
|
return try decoder.decode(GIFSearchResponse.self, from: data)
|
||||||
} catch let decodingError as DecodingError {
|
} catch let decodingError as DecodingError {
|
||||||
let rawResponse = String(data: data, encoding: .utf8)
|
let rawResponse = String(data: data, encoding: .utf8)
|
||||||
throw PurpleGIFAPIError.decodingError(decodingError, rawResponse: rawResponse)
|
let context = GIFAPIErrorContext(
|
||||||
|
serverResponse: rawResponse,
|
||||||
|
statusCode: httpResponse.statusCode,
|
||||||
|
requestURL: url.absoluteString,
|
||||||
|
nip98Event: nip98EventJSON,
|
||||||
|
timestamp: requestTimestamp,
|
||||||
|
extractedMessage: extractErrorMessage(from: data)
|
||||||
|
)
|
||||||
|
let error = PurpleGIFAPIError.decodingError(decodingError, rawResponse: rawResponse, context: context)
|
||||||
|
reportErrorToSentry(error, context: context)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
} catch let error as PurpleGIFAPIError {
|
} catch let error as PurpleGIFAPIError {
|
||||||
|
// Error already has context and was already reported to Sentry
|
||||||
throw error
|
throw error
|
||||||
} catch {
|
} catch {
|
||||||
throw PurpleGIFAPIError.networkError(error)
|
let context = GIFAPIErrorContext(
|
||||||
|
serverResponse: nil,
|
||||||
|
statusCode: nil,
|
||||||
|
requestURL: url.absoluteString,
|
||||||
|
nip98Event: nip98EventJSON,
|
||||||
|
timestamp: requestTimestamp,
|
||||||
|
extractedMessage: nil
|
||||||
|
)
|
||||||
|
let gifError = PurpleGIFAPIError.networkError(error, context: context)
|
||||||
|
reportErrorToSentry(gifError, context: context)
|
||||||
|
throw gifError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps HTTP failures from the Purple proxy into localized client errors.
|
/// Maps HTTP failures from the Purple proxy into localized client errors.
|
||||||
private func mapHTTPError(statusCode: Int, data: Data) -> PurpleGIFAPIError {
|
private func mapHTTPError(statusCode: Int, data: Data, url: URL, nip98Event: String?, timestamp: Date) -> PurpleGIFAPIError {
|
||||||
if statusCode == 401 {
|
let serverResponse = String(data: data, encoding: .utf8)
|
||||||
return .unauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = extractErrorMessage(from: data)
|
let message = extractErrorMessage(from: data)
|
||||||
return .upstreamError(statusCode: statusCode, message: message)
|
|
||||||
|
let context = GIFAPIErrorContext(
|
||||||
|
serverResponse: serverResponse,
|
||||||
|
statusCode: statusCode,
|
||||||
|
requestURL: url.absoluteString,
|
||||||
|
nip98Event: nip98Event,
|
||||||
|
timestamp: timestamp,
|
||||||
|
extractedMessage: message
|
||||||
|
)
|
||||||
|
|
||||||
|
let error: PurpleGIFAPIError
|
||||||
|
if statusCode == 401 {
|
||||||
|
error = .unauthorized(context: context)
|
||||||
|
} else {
|
||||||
|
error = .upstreamError(statusCode: statusCode, message: message, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
reportErrorToSentry(error, context: context)
|
||||||
|
return error
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts a human-readable error message from a JSON or text payload.
|
/// Extracts a human-readable error message from a JSON or text payload.
|
||||||
@@ -132,4 +246,26 @@ actor PurpleGIFAPIClient {
|
|||||||
|
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reports GIF API errors to Sentry with basic diagnostic context.
|
||||||
|
///
|
||||||
|
/// Only includes non-sensitive information: HTTP status code, server error message,
|
||||||
|
/// and timestamp. No URLs, query parameters, or auth details are sent.
|
||||||
|
private func reportErrorToSentry(_ error: PurpleGIFAPIError, context: GIFAPIErrorContext?) {
|
||||||
|
DamusSentry.captureSentryError(error) { scope in
|
||||||
|
scope.setContext(value: [
|
||||||
|
"error_type": String(describing: error),
|
||||||
|
"timestamp": context?.timestamp.ISO8601Format() ?? "unknown",
|
||||||
|
"status_code": context?.statusCode ?? "none",
|
||||||
|
"server_error_message": context?.extractedMessage ?? "none"
|
||||||
|
], key: "gif_api_error")
|
||||||
|
|
||||||
|
// Add tags for easier filtering in Sentry
|
||||||
|
if let statusCode = context?.statusCode {
|
||||||
|
scope.setTag(value: String(statusCode), key: "http_status")
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.setTag(value: "gif_api", key: "error_source")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user