mana-swift-core/Sources/ManaCore/API/AuthenticatedTransport.swift
Till JS 4fc6e94e86 AuthenticatedTransport: optionaler per-Request-Timeout (v2.1.0)
Default nil = URLSession-60-s-Verhalten unverändert. Langsame LLM-/Job-
Endpoints können jetzt höher setzen, sonst bricht der Client ab, während
der Server noch rechnet — Wurzel des "Zeitüberschreitung"-Bugs beim
KI-Drehbuch in audioguide-native.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:18:43 +02:00

88 lines
3.9 KiB
Swift

import Foundation
/// Authentifizierter HTTP-Transport. Setzt automatisch den `Authorization: Bearer`-
/// Header, refreshed bei `401` einmal proaktiv und wiederholt den Request.
///
/// Eine Instanz pro App-Backend (z.B. memoro-api, cards-api). Beim Init
/// die Basis-URL des jeweiligen App-Servers übergeben, nicht die mana-auth-URL.
public actor AuthenticatedTransport {
private let baseURL: URL
private let session: URLSession
private let auth: AuthClient
public init(baseURL: URL, auth: AuthClient, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
self.auth = auth
}
/// Führt einen authentifizierten Request aus. Bei `401` wird der
/// Access-Token einmal refreshed und der Call wiederholt.
///
/// `timeout` überschreibt das `URLRequest`-Default von 60 s. Für langsame
/// Endpoints (LLM-Generierung, serverseitige Jobs) höher setzen, sonst
/// bricht der Client ab, während der Server noch arbeitet. `nil` =
/// URLSession-Default beibehalten.
public func request(
path: String,
method: String = "GET",
body: Data? = nil,
contentType: String = "application/json",
timeout: TimeInterval? = nil
) async throws -> (Data, HTTPURLResponse) {
let token = try await auth.freshAccessToken()
let response = try await send(path: path, method: method, body: body, contentType: contentType, token: token, timeout: timeout)
guard response.1.statusCode == 401 else { return response }
// `freshAccessToken()` hat gerade einen Token geliefert, der noch
// länger als sein Leeway lebt. Ein 401 darauf ist mit hoher
// Wahrscheinlichkeit KEIN Token-Ablauf, sondern ein Authz-/Backend-
// 401 (z.B. hub-api-Proxy-Bridge, fehlende Provisionierung). Den
// als „Token tot" zu lesen und `refreshAccessToken()` zu erzwingen
// ist auf dem geteilten Keychain gefährlich: ein darauf folgender
// invalidierender 401 zählt Richtung Wipe und kann die Flotte
// ausloggen. Nur refreshen+retry, wenn der Token wirklich am Ablauf
// ist — Token-Ablauf ist der einzige 401, den ein Refresh heilt.
if let expiry = JWT.expiry(of: token), expiry.timeIntervalSinceNow > 60 {
return response
}
let refreshed = try await auth.refreshAccessToken()
return try await send(path: path, method: method, body: body, contentType: contentType, token: refreshed, timeout: timeout)
}
private func send(
path: String,
method: String,
body: Data?,
contentType: String,
token: String,
timeout: TimeInterval?
) async throws -> (Data, HTTPURLResponse) {
// String-Konkatenation statt `baseURL.appending(path:)`, weil
// letzteres das `?` in Query-Strings als Path-Component
// behandelt und URL-encoded (`?` → `%3F`) — der Server-Routes-
// Match scheitert dann mit 404. Caller liefert path inkl.
// führendem `/` und optionaler Query.
guard let url = URL(string: baseURL.absoluteString + path) else {
throw AuthError.networkFailure("Ungültige URL: \(path)")
}
var request = URLRequest(url: url)
request.httpMethod = method
if let timeout { request.timeoutInterval = timeout }
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if let body {
request.httpBody = body
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw AuthError.networkFailure("Keine HTTP-Antwort")
}
return (data, http)
} catch let error as URLError {
throw AuthError.networkFailure(error.localizedDescription)
}
}
}