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
private var pingTimers: [AudioSource: Timer] = [:]
private var cancellables = Set<AnyCancellable>()
// Session refresh timers to prevent 30-minute expiry
private var sessionRefreshTimers: [AudioSource: Timer] = [:]
private override init() {
super.init()
@@ -136,6 +139,10 @@ class AudioManager: NSObject, ObservableObject {
pingTimers.values.forEach { $0.invalidate() }
pingTimers.removeAll()
// Invalidate session refresh timers
sessionRefreshTimers.values.forEach { $0.invalidate() }
sessionRefreshTimers.removeAll()
// Reset state
// (isRecording already cleared in stopRecording)
@@ -407,9 +414,9 @@ class AudioManager: NSObject, ObservableObject {
print("Audio tap was invalidated.")
if !self.isRestartingSystemTap {
DispatchQueue.main.async {
print("Tap invalidated unexpectedly. Stopping recording.")
self.stopRecording()
print("Tap invalidated unexpectedly. Restarting system audio tap.")
Task {
await self.restartSystemAudioTap()
}
} else {
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.removeAll()
// Invalidate session refresh timers
sessionRefreshTimers.values.forEach { $0.invalidate() }
sessionRefreshTimers.removeAll()
print("Recording stopped")
}
@@ -520,6 +531,15 @@ class AudioManager: NSObject, ObservableObject {
}
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
// Monitor connection state (ignore if session changed or recording stopped)
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)
print("❌ Receive error (\(source)): \(error)")
DispatchQueue.main.async {
self.errorMessage = errorMsg
}
// Check if this is a session expiry - if so, don't show as persistent error
let isSessionExpiry = errorMsg == ErrorMessage.sessionExpired
// 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)
if isSessionExpiry {
// For session expiry, show temporary message
DispatchQueue.main.async {
self.errorMessage = errorMsg
// 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 {
// 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 {
return ErrorHandler.shared.handleWebSocketCloseCode(closeCode)
}
@@ -666,10 +716,51 @@ class AudioManager: NSObject, ObservableObject {
userFriendlyMessage = ErrorMessage.apiServerError
case "access_denied", "forbidden":
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:
// 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)
if errorMessage.lowercased().contains("input transcription failed") ||
errorMessage.lowercased().contains("transcription failed") {
else if errorMessage.lowercased().contains("input 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."
} else {
userFriendlyMessage = "Transcription error: \(errorMessage)"
+1
View File
@@ -198,4 +198,5 @@ enum ErrorMessage {
static let requestTooLarge = "Request too large. Please try again."
static let unsupportedData = "Unsupported data format. Please update the app."
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)
.sink { [weak self] (isRecording, activeMeetingId) in
guard let self = self else { return }
// If recording started for this meeting, end starting state
if isRecording && activeMeetingId == self.meeting.id {
self.isStartingRecording = false
@@ -113,6 +114,12 @@ class MeetingViewModel: ObservableObject {
recordingSessionManager.$errorMessage
.compactMap { $0 }
.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
print("🚨 Recording Session Manager Error: \(errorMessage)")
}