Skip to content

Commit b6ecc46

Browse files
TheZupZupclaude
andauthored
fix: handle backend failures during active sessions (#38)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31e99f4 commit b6ecc46

2 files changed

Lines changed: 144 additions & 28 deletions

File tree

app/lib/services/app_state.dart

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -144,22 +144,37 @@ class AppState extends ChangeNotifier {
144144
);
145145

146146
Future<void> loadNotebooks() async {
147-
_notebooks = await client.getNotebooks();
148-
notifyListeners();
147+
try {
148+
_notebooks = await client.getNotebooks();
149+
notifyListeners();
150+
} catch (e) {
151+
await _handleBackendFailure(e);
152+
rethrow;
153+
}
149154
}
150155

151156
Future<api.Notebook> createNotebook(String name, String color) async {
152-
final nb = await client.createNotebook(name: name, color: color);
153-
_notebooks.insert(0, nb);
154-
notifyListeners();
155-
return nb;
157+
try {
158+
final nb = await client.createNotebook(name: name, color: color);
159+
_notebooks.insert(0, nb);
160+
notifyListeners();
161+
return nb;
162+
} catch (e) {
163+
await _handleBackendFailure(e);
164+
rethrow;
165+
}
156166
}
157167

158168
Future<void> deleteNotebook(String id) async {
159-
await client.deleteNotebook(id);
160-
_notebooks.removeWhere((n) => n.id == id);
161-
if (_selectedNotebook?.id == id) { _selectedNotebook = null; _notes = []; }
162-
notifyListeners();
169+
try {
170+
await client.deleteNotebook(id);
171+
_notebooks.removeWhere((n) => n.id == id);
172+
if (_selectedNotebook?.id == id) { _selectedNotebook = null; _notes = []; }
173+
notifyListeners();
174+
} catch (e) {
175+
await _handleBackendFailure(e);
176+
rethrow;
177+
}
163178
}
164179

165180
void selectNotebook(api.Notebook? nb) {
@@ -171,35 +186,58 @@ class AppState extends ChangeNotifier {
171186
Future<void> loadNotes({String? notebookId, String? search}) async {
172187
_isLoading = true;
173188
notifyListeners();
174-
try { _notes = await client.getNotes(notebookId: notebookId, search: search); }
175-
catch (_) {}
189+
try {
190+
_notes = await client.getNotes(notebookId: notebookId, search: search);
191+
} catch (e) {
192+
await _handleBackendFailure(e);
193+
}
176194
_isLoading = false;
177195
notifyListeners();
178196
}
179197

180198
Future<api.Note> createNote({required String title, required String noteType, String template = 'blank'}) async {
181-
final note = await client.createNote(
182-
title: title, noteType: noteType,
183-
notebookId: _selectedNotebook?.id, template: template);
184-
_notes.insert(0, note);
185-
notifyListeners();
186-
return note;
199+
try {
200+
final note = await client.createNote(
201+
title: title, noteType: noteType,
202+
notebookId: _selectedNotebook?.id, template: template);
203+
_notes.insert(0, note);
204+
notifyListeners();
205+
return note;
206+
} catch (e) {
207+
await _handleBackendFailure(e);
208+
rethrow;
209+
}
187210
}
188211

189212
Future<void> deleteNote(String id) async {
190-
await client.deleteNote(id);
191-
_notes.removeWhere((n) => n.id == id);
192-
if (_selectedNote?.id == id) _selectedNote = null;
193-
notifyListeners();
213+
try {
214+
await client.deleteNote(id);
215+
_notes.removeWhere((n) => n.id == id);
216+
if (_selectedNote?.id == id) _selectedNote = null;
217+
notifyListeners();
218+
} catch (e) {
219+
await _handleBackendFailure(e);
220+
rethrow;
221+
}
194222
}
195223

196224
Future<void> updateNoteTitle(String id, String title) async {
197-
await client.updateNote(id, title: title);
198-
await loadNotes(notebookId: _selectedNotebook?.id);
225+
try {
226+
await client.updateNote(id, title: title);
227+
await loadNotes(notebookId: _selectedNotebook?.id);
228+
} catch (e) {
229+
await _handleBackendFailure(e);
230+
rethrow;
231+
}
199232
}
200233

201234
Future<void> savePageText(String noteId, int pageNum, String content) async {
202-
await client.savePageText(noteId, pageNum, content);
235+
try {
236+
await client.savePageText(noteId, pageNum, content);
237+
} catch (e) {
238+
await _handleBackendFailure(e);
239+
rethrow;
240+
}
203241
}
204242

205243
void selectNote(api.Note? note) { _selectedNote = note; notifyListeners(); }
@@ -214,6 +252,7 @@ class AppState extends ChangeNotifier {
214252
} catch (e) {
215253
final message = 'Sync failed: $e';
216254
_finishSync(error: message);
255+
await _handleBackendFailure(e);
217256
return message;
218257
}
219258
}
@@ -232,10 +271,25 @@ class AppState extends ChangeNotifier {
232271
return result;
233272
} catch (e) {
234273
_finishSync(error: 'Sync failed: $e');
274+
await _handleBackendFailure(e);
235275
rethrow;
236276
}
237277
}
238278

279+
/// Marks the backend as unavailable after an API call fails mid-session and
280+
/// refreshes [_notebooks]/[_notes] from the local SQLite store so the UI can
281+
/// keep working offline. Reuses the same flag and message as the
282+
/// startup-time path so the existing offline banner kicks in unchanged.
283+
Future<void> _handleBackendFailure(Object _) async {
284+
final wasAvailable = _isBackendAvailable;
285+
_isBackendAvailable = false;
286+
_backendErrorMessage = 'Offline mode — backend unavailable';
287+
if (wasAvailable) {
288+
await _loadLocalFallback();
289+
}
290+
notifyListeners();
291+
}
292+
239293
void _beginSync() {
240294
_isSyncing = true;
241295
_syncError = null;

app/test/services/app_state_test.dart

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import 'package:nexanote/services/local_note_service.dart';
99
import 'package:nexanote/services/sync_service.dart';
1010

1111
class _StubApi extends api.ApiClient {
12-
_StubApi({this.shouldThrow = false, this.pingResult = true})
13-
: super(baseUrl: 'http://stub.test');
14-
final bool shouldThrow;
12+
_StubApi({bool shouldThrow = false, this.pingResult = true})
13+
: shouldThrow = shouldThrow,
14+
super(baseUrl: 'http://stub.test');
15+
bool shouldThrow;
1516
final bool pingResult;
1617

1718
@override
@@ -133,6 +134,67 @@ void main() {
133134
expect(onlineState.backendErrorMessage, isNull);
134135
});
135136

137+
test('backend failure mid-session flips isBackendAvailable from true to false',
138+
() async {
139+
await state.initLocal();
140+
final localNb = await service.createNotebook('Local NB');
141+
await service.createNote('Local note', notebookId: localNb.id);
142+
143+
final stub = _StubApi();
144+
final liveState = AppState(
145+
localService: service,
146+
clientFactory: (_) => stub,
147+
);
148+
149+
await liveState.connect();
150+
expect(liveState.isBackendAvailable, isTrue);
151+
expect(liveState.backendErrorMessage, isNull);
152+
153+
// Simulate the backend going down mid-session.
154+
stub.shouldThrow = true;
155+
await expectLater(liveState.loadNotebooks(), throwsA(isA<Exception>()));
156+
157+
expect(liveState.isBackendAvailable, isFalse);
158+
expect(liveState.backendErrorMessage, isNotNull);
159+
expect(liveState.backendErrorMessage, contains('Offline'));
160+
// Local data is still reachable so the UI does not bounce back to
161+
// ConnectScreen and the editor keeps working.
162+
expect(liveState.hasLocalData, isTrue);
163+
expect(liveState.notebooks.map((n) => n.name), contains('Local NB'));
164+
expect(liveState.notes.map((n) => n.title), contains('Local note'));
165+
166+
// SQLite save/load keeps working independently of the backend flag.
167+
await liveState.localService.createNote('Written offline',
168+
notebookId: localNb.id);
169+
final notes =
170+
await liveState.localService.getNotesForNotebook(localNb.id);
171+
expect(notes.map((n) => n.title), contains('Written offline'));
172+
});
173+
174+
test('syncNow marks backend unavailable when sync fails mid-session',
175+
() async {
176+
await state.initLocal();
177+
final stub = _StubApi();
178+
final liveState = AppState(
179+
localService: service,
180+
clientFactory: (_) => stub,
181+
);
182+
183+
await liveState.connect();
184+
expect(liveState.isBackendAvailable, isTrue);
185+
186+
stub.shouldThrow = true;
187+
final svc = SyncService(apiClient: stub, local: service);
188+
await expectLater(
189+
liveState.syncNow(service: svc), throwsA(isA<Exception>()));
190+
191+
expect(liveState.isSyncing, isFalse);
192+
expect(liveState.syncError, isNotNull);
193+
expect(liveState.syncError, contains('Sync failed'));
194+
expect(liveState.isBackendAvailable, isFalse);
195+
expect(liveState.backendErrorMessage, contains('Offline'));
196+
});
197+
136198
test('syncNow records syncError and resets isSyncing when sync fails',
137199
() async {
138200
await state.initLocal();

0 commit comments

Comments
 (0)