Merge pull request #3779 from damus-io/gif_diagnostics
Add detailed diagnostics reporting to GIF keyboard failures
This commit is contained in:
@@ -19,11 +19,18 @@ enum HTTPPayloadType: String {
|
||||
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) {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.httpBody = payload
|
||||
|
||||
/// Creates a NIP-98 authentication event for HTTP requests.
|
||||
///
|
||||
/// This generates the Nostr event used for authenticating HTTP requests according to NIP-98.
|
||||
/// 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 = [
|
||||
["u", url.absoluteString],
|
||||
["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])
|
||||
}
|
||||
|
||||
let auth_note = NdbNote(
|
||||
return NdbNote(
|
||||
content: "",
|
||||
keypair: auth_keypair,
|
||||
kind: 27235,
|
||||
tags: tag_pairs,
|
||||
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_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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
func userPresentableError(action: String, currentQuery: String?) -> ErrorView.UserPresentableError {
|
||||
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 {
|
||||
case .unauthorized:
|
||||
return .init(
|
||||
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."),
|
||||
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)"
|
||||
)
|
||||
case .unauthorized(let context):
|
||||
// For 401 errors, show the server's error message with context
|
||||
let serverMessage = context?.extractedMessage
|
||||
|
||||
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:
|
||||
return .init(
|
||||
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."),
|
||||
technical_info: "GIF picker invalid URL while \(action); context=\(queryContext)"
|
||||
technical_info: fullTechnicalInfo
|
||||
)
|
||||
case .invalidResponse:
|
||||
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."),
|
||||
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 detailedInfo = "\(baseTechnicalInfo); error=\(String(describing: decodingError)); response=\(responseText)"
|
||||
let withContext = self.context.map { "; \($0.debugDescription)" } ?? ""
|
||||
return .init(
|
||||
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."),
|
||||
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(
|
||||
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."),
|
||||
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(
|
||||
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."),
|
||||
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 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 {
|
||||
case invalidURL
|
||||
case invalidResponse
|
||||
case unauthorized
|
||||
case upstreamError(statusCode: Int, message: String?)
|
||||
case networkError(Error)
|
||||
case decodingError(Error, rawResponse: String?)
|
||||
case invalidResponse(context: GIFAPIErrorContext?)
|
||||
case unauthorized(context: GIFAPIErrorContext?)
|
||||
case upstreamError(statusCode: Int, message: String?, context: GIFAPIErrorContext?)
|
||||
case networkError(Error, context: GIFAPIErrorContext?)
|
||||
case decodingError(Error, rawResponse: String?, context: GIFAPIErrorContext?)
|
||||
|
||||
var errorDescription: String? {
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
case .networkError(let error):
|
||||
case .networkError(let error, _):
|
||||
return error.localizedDescription
|
||||
case .decodingError:
|
||||
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 {
|
||||
@@ -71,44 +113,116 @@ actor PurpleGIFAPIClient {
|
||||
|
||||
/// Performs an authenticated request against the Purple GIF proxy.
|
||||
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 {
|
||||
// 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(
|
||||
method: .get,
|
||||
url: url,
|
||||
payload: nil,
|
||||
payload_type: nil,
|
||||
auth_keypair: purple.keypair
|
||||
auth_note: nip98Event
|
||||
)
|
||||
|
||||
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 {
|
||||
throw mapHTTPError(statusCode: httpResponse.statusCode, data: data)
|
||||
throw mapHTTPError(
|
||||
statusCode: httpResponse.statusCode,
|
||||
data: data,
|
||||
url: url,
|
||||
nip98Event: nip98EventJSON,
|
||||
timestamp: requestTimestamp
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
return try decoder.decode(GIFSearchResponse.self, from: data)
|
||||
} catch let decodingError as DecodingError {
|
||||
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 {
|
||||
// Error already has context and was already reported to Sentry
|
||||
throw error
|
||||
} 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.
|
||||
private func mapHTTPError(statusCode: Int, data: Data) -> PurpleGIFAPIError {
|
||||
if statusCode == 401 {
|
||||
return .unauthorized
|
||||
}
|
||||
|
||||
private func mapHTTPError(statusCode: Int, data: Data, url: URL, nip98Event: String?, timestamp: Date) -> PurpleGIFAPIError {
|
||||
let serverResponse = String(data: data, encoding: .utf8)
|
||||
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.
|
||||
@@ -132,4 +246,26 @@ actor PurpleGIFAPIClient {
|
||||
|
||||
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