Skip to content

Commit 6bda0cc

Browse files
Adronclaude
andcommitted
feat(errors): friendly loading-error messages + rotating debug log
Loading failures surfaced technical text straight from APIError (e.g. "Decoding ListRowDTO failed: …"). APIError.errorDescription now returns a new userFacingMessage — friendly copy for the client-only cases (decoding/transport/bare status), with server-written 4xx messages preserved verbatim. The technical form stays in .description for logs, so every existing error banner improves with no view edits. Adds AppLog (facade over os.Logger) + a rotating FileLog that writes to Library/Logs/InterlinedList/interlinedlist.log inside the app container. APIClient logs the full technical cause — request path plus the complete DecodingError coding path — at each decode/transport/ non-2xx failure; the user only ever sees the friendly banner. Tests: APIError user-facing vs. technical split, FileLog write + rotation, and a test-isolation guard so unit runs never touch the real ~/Library. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b4b90e6 commit 6bda0cc

5 files changed

Lines changed: 334 additions & 14 deletions

File tree

Packages/InterlinedKit/Sources/InterlinedKit/APIClient/APIClient.swift

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import Foundation
2-
import os
32

43
// MARK: - APIClientProtocol
54

@@ -83,7 +82,7 @@ public final class APIClient: APIClientProtocol {
8382
private let decoder: JSONDecoder
8483
private let encoder: JSONEncoder
8584
private let retryPolicy: RetryPolicy
86-
private let logger: Logger
85+
private let appLog: AppLog
8786

8887
public init(
8988
baseURL: URL = URL(string: "https://interlinedlist.com")!,
@@ -99,10 +98,7 @@ public final class APIClient: APIClientProtocol {
9998
self.decoder = decoder
10099
self.encoder = encoder
101100
self.retryPolicy = retryPolicy
102-
self.logger = Logger(
103-
subsystem: Bundle.main.bundleIdentifier ?? "com.interlinedlist.kit",
104-
category: "APIClient"
105-
)
101+
self.appLog = AppLog(category: "APIClient")
106102
}
107103

108104
// MARK: APIClientProtocol
@@ -114,9 +110,13 @@ public final class APIClient: APIClientProtocol {
114110
do {
115111
return try decoder.decode(Response.self, from: data)
116112
} catch {
113+
// Log the full decoder detail (coding path / key) with the request
114+
// path — the user only ever sees `APIError.userFacingMessage`.
115+
let detail = String(reflecting: error)
116+
appLog.error("Decode failed [\(request.path)] type=\(String(describing: Response.self)): \(detail)")
117117
throw APIError.decoding(
118118
type: String(describing: Response.self),
119-
message: error.localizedDescription
119+
message: detail
120120
)
121121
}
122122
}
@@ -141,9 +141,13 @@ public final class APIClient: APIClientProtocol {
141141
// that is the correct "no limit on this route" signal.
142142
return (decoded, RateLimitInfo.parse(from: response))
143143
} catch {
144+
// Log the full decoder detail (coding path / key) with the request
145+
// path — the user only ever sees `APIError.userFacingMessage`.
146+
let detail = String(reflecting: error)
147+
appLog.error("Decode failed [\(request.path)] type=\(String(describing: Response.self)): \(detail)")
144148
throw APIError.decoding(
145149
type: String(describing: Response.self),
146-
message: error.localizedDescription
150+
message: detail
147151
)
148152
}
149153
}
@@ -166,7 +170,7 @@ public final class APIClient: APIClientProtocol {
166170
// transparently try once via the session transport before we
167171
// give up. This catches future API drift in either direction.
168172
if case .unauthorized = error, request.auth == .bearer {
169-
logger.warning("Bearer request returned 401 — retrying via session transport")
173+
appLog.warning("Bearer request returned 401 [\(request.path)] — retrying via session transport")
170174
return try await performWithRetry(request, forceSession: true)
171175
}
172176
throw error
@@ -220,13 +224,16 @@ public final class APIClient: APIClientProtocol {
220224
// and be indistinguishable from a genuine transport failure).
221225
throw CancellationError()
222226
} catch {
227+
appLog.error("Transport failed [\(request.path)]: \(String(reflecting: error))")
223228
throw APIError.transport(message: error.localizedDescription)
224229
}
225230

226231
guard (200..<300).contains(response.statusCode) else {
232+
let serverMessage = decodeServerMessage(from: data)
233+
appLog.notice("HTTP \(response.statusCode) [\(request.path)]: \(serverMessage ?? "no server message")")
227234
throw APIError.from(
228235
statusCode: response.statusCode,
229-
serverMessage: decodeServerMessage(from: data),
236+
serverMessage: serverMessage,
230237
retryAfter: parseRetryAfter(response.value(forHTTPHeaderField: "Retry-After"))
231238
)
232239
}

Packages/InterlinedKit/Sources/InterlinedKit/Errors/APIError.swift

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,43 @@ extension APIError: Equatable {
8181
}
8282

8383
extension APIError: LocalizedError, CustomStringConvertible {
84-
/// The server-supplied human message if one is available, otherwise a
85-
/// concise developer-facing description of the case. Suitable for both
86-
/// `NSAlert` body text and `os.Logger` output.
87-
public var errorDescription: String? { description }
84+
/// The message shown to the user. Points at `userFacingMessage` so that
85+
/// anything rendering `error.localizedDescription` (the whole app does)
86+
/// gets friendly, non-technical copy. The technical form lives in
87+
/// `description` and is what goes to the debug log.
88+
public var errorDescription: String? { userFacingMessage }
8889

90+
/// A friendly, non-technical message safe to show in a loading/error UI.
91+
///
92+
/// Server-supplied messages (400/403/404/429) are already human-written on
93+
/// InterlinedList and are preserved verbatim. The client-only cases
94+
/// (`decoding`, `transport`) and bare status codes — whose `description`
95+
/// is developer jargon like "Decoding ListRowDTO failed: …" — get a
96+
/// generic, reassuring message instead. The real cause is captured in the
97+
/// debug log via `description`.
98+
public var userFacingMessage: String {
99+
switch self {
100+
case .transport:
101+
return "Can’t reach InterlinedList. Check your internet connection and try again."
102+
case .decoding:
103+
return "InterlinedList sent back something we couldn’t read. Please try again in a moment."
104+
case .unauthorized(let message):
105+
return message ?? "Your session has expired. Please sign in again."
106+
case .forbidden(let message):
107+
return message ?? "You don’t have permission to do that."
108+
case .notFound(let message):
109+
return message ?? "We couldn’t find what you were looking for."
110+
case .badRequest(let message):
111+
return message ?? "That request couldn’t be completed. Please check your input and try again."
112+
case .rateLimited(let message, _):
113+
return message ?? "You’re doing that a little too quickly. Please wait a moment and try again."
114+
case .httpStatus(_, let message):
115+
return message ?? "Something went wrong. Please try again."
116+
}
117+
}
118+
119+
/// The technical description — developer jargon, request/decoder detail —
120+
/// used for `os.Logger` and the debug-log file. Never shown to the user.
89121
public var description: String {
90122
switch self {
91123
case .transport(let message):
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import Foundation
2+
import os
3+
4+
/// App-wide logging facade.
5+
///
6+
/// Every call mirrors to Apple's unified logging (visible live in Console.app
7+
/// and `log stream`) **and** appends to a rotating file under the app's
8+
/// container so a user can send it for debugging later. Under the sandbox the
9+
/// file lives at:
10+
///
11+
/// ~/Library/Containers/<bundle-id>/Data/Library/Logs/InterlinedList/interlinedlist.log
12+
///
13+
/// The unified-logging side keeps the existing on-device behaviour; the file
14+
/// side is the new, retrievable artifact. User-facing UI text is produced
15+
/// separately (see `APIError.userFacingMessage`) — the log holds the full
16+
/// technical detail, the UI shows the friendly message.
17+
public struct AppLog: Sendable {
18+
19+
/// Log severity. Mirrors the `os.Logger` levels we actually use.
20+
public enum Level: String, Sendable {
21+
case debug, info, notice, warning, error, fault
22+
}
23+
24+
/// The unified-logging subsystem shared across the app and its packages.
25+
public static let subsystem = "com.interlinedlist.macos"
26+
27+
private let category: String
28+
private let osLogger: Logger
29+
private let file: FileLog
30+
31+
/// - Parameters:
32+
/// - category: groups related messages (e.g. `"APIClient"`).
33+
/// - file: the file sink. Defaults to the process-wide `.shared` log so
34+
/// every category writes to the same file; injectable for tests.
35+
public init(category: String, file: FileLog = .shared) {
36+
self.category = category
37+
self.osLogger = Logger(subsystem: AppLog.subsystem, category: category)
38+
self.file = file
39+
}
40+
41+
public func error(_ message: @autoclosure () -> String) { log(.error, message()) }
42+
public func warning(_ message: @autoclosure () -> String) { log(.warning, message()) }
43+
public func notice(_ message: @autoclosure () -> String) { log(.notice, message()) }
44+
public func info(_ message: @autoclosure () -> String) { log(.info, message()) }
45+
public func debug(_ message: @autoclosure () -> String) { log(.debug, message()) }
46+
47+
public func log(_ level: Level, _ message: String) {
48+
// `.public` privacy: these strings are already scrubbed of user data
49+
// by the callers (we log error *structure*, not response bodies).
50+
switch level {
51+
case .debug: osLogger.debug("\(message, privacy: .public)")
52+
case .info: osLogger.info("\(message, privacy: .public)")
53+
case .notice: osLogger.notice("\(message, privacy: .public)")
54+
case .warning: osLogger.warning("\(message, privacy: .public)")
55+
case .error: osLogger.error("\(message, privacy: .public)")
56+
case .fault: osLogger.fault("\(message, privacy: .public)")
57+
}
58+
file.append(level: level, category: category, message: message)
59+
}
60+
}
61+
62+
/// The rotating file sink behind `AppLog`.
63+
///
64+
/// Thread-safety is provided by a private serial queue; writes are
65+
/// fire-and-forget so logging never blocks the caller. `@unchecked Sendable`
66+
/// is sound because every stored property is immutable and the only mutable
67+
/// state (the file on disk, the shared date formatter) is touched solely on
68+
/// `queue`.
69+
public final class FileLog: @unchecked Sendable {
70+
71+
/// The process-wide log used by `AppLog` when no sink is injected.
72+
public static let shared = FileLog()
73+
74+
private let queue = DispatchQueue(label: "com.interlinedlist.filelog")
75+
private let fileURL: URL?
76+
private let maxBytes: Int
77+
private let formatter: ISO8601DateFormatter
78+
79+
/// - Parameters:
80+
/// - directory: where the log file is written. `nil` disables file
81+
/// logging entirely (every `append` becomes a no-op) — used under
82+
/// XCTest so unit runs never touch the real `~/Library`.
83+
/// - fileName: the active log file's name.
84+
/// - maxBytes: rotate once the active file reaches this size. One
85+
/// previous generation is kept as `<fileName>.1`.
86+
public init(
87+
directory: URL? = FileLog.defaultDirectory(),
88+
fileName: String = "interlinedlist.log",
89+
maxBytes: Int = 5 * 1024 * 1024
90+
) {
91+
self.maxBytes = maxBytes
92+
let formatter = ISO8601DateFormatter()
93+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
94+
self.formatter = formatter
95+
96+
if let directory {
97+
try? FileManager.default.createDirectory(
98+
at: directory,
99+
withIntermediateDirectories: true
100+
)
101+
self.fileURL = directory.appendingPathComponent(fileName)
102+
} else {
103+
self.fileURL = nil
104+
}
105+
}
106+
107+
/// The app's log directory inside the (sandbox) container's `Library/Logs`,
108+
/// or `nil` when running under XCTest so tests don't write to real Library.
109+
public static func defaultDirectory() -> URL? {
110+
if isRunningUnderTests { return nil }
111+
guard let library = FileManager.default
112+
.urls(for: .libraryDirectory, in: .userDomainMask).first else { return nil }
113+
return library.appendingPathComponent("Logs/InterlinedList", isDirectory: true)
114+
}
115+
116+
/// True inside a unit-test host. `XCTestConfigurationFilePath` covers Xcode;
117+
/// SwiftPM's `swift test` doesn't set it, so also sniff the loaded XCTest
118+
/// runtime (never linked into the shipping app).
119+
private static var isRunningUnderTests: Bool {
120+
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
121+
|| NSClassFromString("XCTestCase") != nil
122+
}
123+
124+
/// The active log file on disk, if file logging is enabled. Useful for a
125+
/// future "Export Logs…" affordance.
126+
public var currentFileURL: URL? { fileURL }
127+
128+
/// Appends one line: `<ISO-8601 timestamp> [LEVEL] <category>: <message>`.
129+
public func append(level: AppLog.Level, category: String, message: String) {
130+
guard let fileURL else { return }
131+
let now = Date()
132+
queue.async {
133+
self.rotateIfNeeded(fileURL: fileURL)
134+
let line = "\(self.formatter.string(from: now)) "
135+
+ "[\(level.rawValue.uppercased())] \(category): \(message)\n"
136+
guard let data = line.data(using: .utf8) else { return }
137+
if let handle = try? FileHandle(forWritingTo: fileURL) {
138+
defer { try? handle.close() }
139+
_ = try? handle.seekToEnd()
140+
try? handle.write(contentsOf: data)
141+
} else {
142+
// File doesn't exist yet (first write, or just rotated).
143+
try? data.write(to: fileURL, options: .atomic)
144+
}
145+
}
146+
}
147+
148+
/// Rotates `interlinedlist.log` → `interlinedlist.log.1` once it grows past
149+
/// `maxBytes`, discarding any older generation. Must run on `queue`.
150+
private func rotateIfNeeded(fileURL: URL) {
151+
let fm = FileManager.default
152+
guard
153+
let attributes = try? fm.attributesOfItem(atPath: fileURL.path),
154+
let size = attributes[.size] as? Int,
155+
size >= maxBytes
156+
else { return }
157+
let backup = fileURL.appendingPathExtension("1")
158+
try? fm.removeItem(at: backup)
159+
try? fm.moveItem(at: fileURL, to: backup)
160+
}
161+
}

Packages/InterlinedKit/Tests/InterlinedKitTests/APIErrorTests.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,49 @@ final class APIErrorTests: XCTestCase {
9090
XCTAssertEqual(error.description, "Unauthorized")
9191
}
9292

93+
// MARK: - User-facing vs. technical messages
94+
95+
func test_givenDecodingError_whenUserFacing_thenHidesTechnicalDetail() {
96+
let error = APIError.decoding(type: "ListRowDTO", message: "keyNotFound(CodingKeys(stringValue: \"id\"))")
97+
98+
// The UI (localizedDescription → errorDescription → userFacingMessage)
99+
// must not leak the decoder jargon…
100+
XCTAssertFalse(error.userFacingMessage.contains("ListRowDTO"))
101+
XCTAssertFalse(error.userFacingMessage.lowercased().contains("decoding"))
102+
XCTAssertFalse(error.userFacingMessage.lowercased().contains("keynotfound"))
103+
XCTAssertEqual(error.errorDescription, error.userFacingMessage)
104+
105+
// …but the technical form (for the debug log) still carries it.
106+
XCTAssertTrue(error.description.contains("ListRowDTO"))
107+
XCTAssertTrue(error.description.contains("keyNotFound"))
108+
}
109+
110+
func test_givenTransportError_whenUserFacing_thenIsFriendlyConnectionMessage() {
111+
let error = APIError.transport(message: "The request timed out.")
112+
XCTAssertFalse(error.userFacingMessage.lowercased().contains("network error"))
113+
XCTAssertTrue(error.userFacingMessage.contains("connection"))
114+
// Technical detail preserved for the log.
115+
XCTAssertEqual(error.description, "Network error: The request timed out.")
116+
}
117+
118+
func test_givenServerMessage_whenUserFacing_thenPreservesItVerbatim() {
119+
// 4xx server messages are already human-written — keep them.
120+
XCTAssertEqual(
121+
APIError.forbidden(serverMessage: "Email not verified").userFacingMessage,
122+
"Email not verified"
123+
)
124+
XCTAssertEqual(
125+
APIError.badRequest(serverMessage: "Name is required").userFacingMessage,
126+
"Name is required"
127+
)
128+
}
129+
130+
func test_givenStatusWithoutServerMessage_whenUserFacing_thenFriendlyFallback() {
131+
let error = APIError.httpStatus(code: 500, serverMessage: nil)
132+
XCTAssertFalse(error.userFacingMessage.contains("500"))
133+
XCTAssertEqual(error.description, "HTTP 500")
134+
}
135+
93136
// MARK: - APIErrorBody decoding
94137

95138
func test_givenErrorBodyJSON_whenDecoded_thenExtractsMessage() throws {

0 commit comments

Comments
 (0)