fix: auto refresh websocket session (#51)

This commit is contained in:
Owen Gretzinger
2025-08-05 11:57:27 -04:00
committed by GitHub
parent ef429ddcc9
commit aebc4eb7ba
3 changed files with 113 additions and 14 deletions
+105 -14
View File
@@ -44,6 +44,9 @@ class AudioManager: NSObject, ObservableObject {
// Add ping timers to keep WebSocket connections alive // Add ping timers to keep WebSocket connections alive
private var pingTimers: [AudioSource: Timer] = [:] private var pingTimers: [AudioSource: Timer] = [:]
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
// Session refresh timers to prevent 30-minute expiry
private var sessionRefreshTimers: [AudioSource: Timer] = [:]
private override init() { private override init() {
super.init() super.init()
@@ -136,6 +139,10 @@ class AudioManager: NSObject, ObservableObject {
pingTimers.values.forEach { $0.invalidate() } pingTimers.values.forEach { $0.invalidate() }
pingTimers.removeAll() pingTimers.removeAll()
// Invalidate session refresh timers
sessionRefreshTimers.values.forEach { $0.invalidate() }
sessionRefreshTimers.removeAll()
// Reset state // Reset state
// (isRecording already cleared in stopRecording) // (isRecording already cleared in stopRecording)
@@ -407,9 +414,9 @@ class AudioManager: NSObject, ObservableObject {
print("Audio tap was invalidated.") print("Audio tap was invalidated.")
if !self.isRestartingSystemTap { if !self.isRestartingSystemTap {
DispatchQueue.main.async { print("Tap invalidated unexpectedly. Restarting system audio tap.")
print("Tap invalidated unexpectedly. Stopping recording.") Task {
self.stopRecording() await self.restartSystemAudioTap()
} }
} else { } else {
print("Tap invalidated as part of a restart. Not stopping recording.") print("Tap invalidated as part of a restart. Not stopping recording.")
@@ -451,6 +458,10 @@ class AudioManager: NSObject, ObservableObject {
pingTimers.values.forEach { $0.invalidate() } pingTimers.values.forEach { $0.invalidate() }
pingTimers.removeAll() pingTimers.removeAll()
// Invalidate session refresh timers
sessionRefreshTimers.values.forEach { $0.invalidate() }
sessionRefreshTimers.removeAll()
print("Recording stopped") print("Recording stopped")
} }
@@ -520,6 +531,15 @@ class AudioManager: NSObject, ObservableObject {
} }
pingTimers[source] = pingTimer pingTimers[source] = pingTimer
// Set up session refresh timer to prevent 30-minute expiry (refresh after 28 minutes)
sessionRefreshTimers[source]?.invalidate()
let sessionRefreshTimer = Timer.scheduledTimer(withTimeInterval: 28 * 60.0, repeats: false) { [weak self] _ in
guard let self = self, self.isRecording else { return }
print("📝 Proactively refreshing session for \(source) to prevent expiry...")
self.connectToOpenAIRealtime(source: source)
}
sessionRefreshTimers[source] = sessionRefreshTimer
let thisSession = sessionID let thisSession = sessionID
// Monitor connection state (ignore if session changed or recording stopped) // Monitor connection state (ignore if session changed or recording stopped)
DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self, weak task] in DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self, weak task] in
@@ -614,15 +634,31 @@ class AudioManager: NSObject, ObservableObject {
let errorMsg = self.handleWebSocketError(error, source: source) let errorMsg = self.handleWebSocketError(error, source: source)
print("❌ Receive error (\(source)): \(error)") print("❌ Receive error (\(source)): \(error)")
DispatchQueue.main.async { // Check if this is a session expiry - if so, don't show as persistent error
self.errorMessage = errorMsg let isSessionExpiry = errorMsg == ErrorMessage.sessionExpired
}
// Only attempt reconnect for network errors, not API errors if isSessionExpiry {
if ErrorHandler.shared.shouldRetry(error) { // For session expiry, show temporary message
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in DispatchQueue.main.async {
guard let self = self, self.isRecording, self.sessionID == sessionID else { return } self.errorMessage = errorMsg
self.connectToOpenAIRealtime(source: source) // Clear the message after a few seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
if self.errorMessage == errorMsg {
self.errorMessage = nil
}
}
}
} else {
DispatchQueue.main.async {
self.errorMessage = errorMsg
}
// Only attempt reconnect for network errors, not API errors
if ErrorHandler.shared.shouldRetry(error) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
guard let self = self, self.isRecording, self.sessionID == sessionID else { return }
self.connectToOpenAIRealtime(source: source)
}
} }
} }
} }
@@ -630,7 +666,21 @@ class AudioManager: NSObject, ObservableObject {
} }
private func handleWebSocketError(_ error: Error, source: AudioSource) -> String { private func handleWebSocketError(_ error: Error, source: AudioSource) -> String {
// Check for WebSocket close codes first // Check for session expiry in error description first
let errorDescription = error.localizedDescription.lowercased()
if errorDescription.contains("session hit the maximum duration") ||
errorDescription.contains("session expired") {
// Handle session expiry by automatically restarting the connection
print("📝 Session expired for \(source) (WebSocket error), attempting to restart connection...")
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
guard let self = self, self.isRecording else { return }
self.connectToOpenAIRealtime(source: source)
}
// Return session expired message but don't stop recording
return ErrorMessage.sessionExpired
}
// Check for WebSocket close codes
if let closeCode = (error as NSError?)?.userInfo["closeCode"] as? Int { if let closeCode = (error as NSError?)?.userInfo["closeCode"] as? Int {
return ErrorHandler.shared.handleWebSocketCloseCode(closeCode) return ErrorHandler.shared.handleWebSocketCloseCode(closeCode)
} }
@@ -666,10 +716,51 @@ class AudioManager: NSObject, ObservableObject {
userFriendlyMessage = ErrorMessage.apiServerError userFriendlyMessage = ErrorMessage.apiServerError
case "access_denied", "forbidden": case "access_denied", "forbidden":
userFriendlyMessage = ErrorMessage.accessForbidden userFriendlyMessage = ErrorMessage.accessForbidden
case "session_expired":
// Handle session expiry by automatically restarting the connection
print("📝 Session expired for \(source), attempting to restart connection...")
userFriendlyMessage = ErrorMessage.sessionExpired
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
guard let self = self, self.isRecording else { return }
self.connectToOpenAIRealtime(source: source)
}
// Show informational message but don't stop recording
DispatchQueue.main.async {
self.errorMessage = userFriendlyMessage
// Clear the message after a few seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
if self.errorMessage == userFriendlyMessage {
self.errorMessage = nil
}
}
}
return
default: default:
// Check for session expiry in the error message
if errorMessage.lowercased().contains("session hit the maximum duration") ||
errorMessage.lowercased().contains("session expired") {
// Handle session expiry by automatically restarting the connection
print("📝 Session expired for \(source), attempting to restart connection...")
userFriendlyMessage = ErrorMessage.sessionExpired
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
guard let self = self, self.isRecording else { return }
self.connectToOpenAIRealtime(source: source)
}
// Show informational message but don't stop recording
DispatchQueue.main.async {
self.errorMessage = userFriendlyMessage
// Clear the message after a few seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
if self.errorMessage == userFriendlyMessage {
self.errorMessage = nil
}
}
}
return
}
// Check if this is a transcription failure (often indicates insufficient funds) // Check if this is a transcription failure (often indicates insufficient funds)
if errorMessage.lowercased().contains("input transcription failed") || else if errorMessage.lowercased().contains("input transcription failed") ||
errorMessage.lowercased().contains("transcription failed") { errorMessage.lowercased().contains("transcription failed") {
userFriendlyMessage = "\(errorMessage)\n\nNote: This error typically occurs when your OpenAI account has insufficient funds. Please check your account balance and add credits if needed." userFriendlyMessage = "\(errorMessage)\n\nNote: This error typically occurs when your OpenAI account has insufficient funds. Please check your account balance and add credits if needed."
} else { } else {
userFriendlyMessage = "Transcription error: \(errorMessage)" userFriendlyMessage = "Transcription error: \(errorMessage)"
+1
View File
@@ -198,4 +198,5 @@ enum ErrorMessage {
static let requestTooLarge = "Request too large. Please try again." static let requestTooLarge = "Request too large. Please try again."
static let unsupportedData = "Unsupported data format. Please update the app." static let unsupportedData = "Unsupported data format. Please update the app."
static let connectionLost = "Connection lost. Please try again." static let connectionLost = "Connection lost. Please try again."
static let sessionExpired = "Session expired and has been automatically renewed. Transcription will continue."
} }
@@ -100,6 +100,7 @@ class MeetingViewModel: ObservableObject {
Publishers.CombineLatest(recordingSessionManager.$isRecording, recordingSessionManager.$activeMeetingId) Publishers.CombineLatest(recordingSessionManager.$isRecording, recordingSessionManager.$activeMeetingId)
.sink { [weak self] (isRecording, activeMeetingId) in .sink { [weak self] (isRecording, activeMeetingId) in
guard let self = self else { return } guard let self = self else { return }
// If recording started for this meeting, end starting state // If recording started for this meeting, end starting state
if isRecording && activeMeetingId == self.meeting.id { if isRecording && activeMeetingId == self.meeting.id {
self.isStartingRecording = false self.isStartingRecording = false
@@ -113,6 +114,12 @@ class MeetingViewModel: ObservableObject {
recordingSessionManager.$errorMessage recordingSessionManager.$errorMessage
.compactMap { $0 } .compactMap { $0 }
.sink { [weak self] errorMessage in .sink { [weak self] errorMessage in
// Suppress non-critical, self-healing errors that should not distract the user
let lowercased = errorMessage.lowercased()
if errorMessage == ErrorMessage.sessionExpired || lowercased.contains("socket is not connected") {
print("️ Suppressed non-critical error: \(errorMessage)")
return
}
self?.errorMessage = errorMessage self?.errorMessage = errorMessage
print("🚨 Recording Session Manager Error: \(errorMessage)") print("🚨 Recording Session Manager Error: \(errorMessage)")
} }