agentleFS
NarekManukyanNarekManukyan/flutter-mobx-kit/skills/handle-errors/SKILL.md

handle-errors

Skill0 starsChanged 56 days agoMITView on GitHub

Write a try/catch the house way — which IssueTrackingService method to call (trackIssue / trackHandledError / trackSilentError), the golden rule that every catch tracks or rethrows, what a catch should catch, the five use-case error patterns, restore-then-rethrow for stores, rollback notices for optimistic changes, recovery (retry, fallback, state reset), and how to test it. Use when writing any catch block, a store action or use case that calls the API, background work, or platform-channel code.

The file

We split this skill into the rules it gives an agent

8 rules. About 0 are common, 3 are distinctive, and 5 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Every method requires an operation name: context: on trackIssue and trackSilentError, handlingContext: on trackHandledError.

    line 64
  2. Distinctive:

    no navigator context yet — reported, no notice.

    line 89
  3. Very specific:

    Only branch on DioExceptionType / status code at the call site when your UI does something different for it (a "not found" empty state, a …

    line 91
  4. Very specific:

    When the caller has to tell "not found" from "offline", return a sealed result, not null.

    line 146
  5. Distinctive:

    Put the state back, whatever failed.

    line 152
  6. Very specific:

    If the caller must know it failed, reset the flag and rethrow instead of tracking, and let the caller track.

    line 228
  7. Distinctive:

    State reset: put the store back into a state the page can render (error state, previous data), then track, or rethrow and let the caller …

    line 252
  8. Very specific:

    When the page shows its own error, also verifyNever on trackIssue — that is the double-message guard.

    line 278

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Handle errors

Governed by ADR-0020. Service: lib/core/services/issue_tracking_service.dart.

Concepts behind this playbook: error-handling-guidelines.

No service in this repo yet? Create it before writing the first catch. Port the reference implementation and its test from the boilerplate — issue_tracking_service.dart, error_notice.dart, issue_tracking_service_test.dart — and adapt three things to the app: how it shows a notice (its navigator / overlay API), its design tokens, and ignoredStatusCodes (whatever its interceptors already handle globally). Keep the three method names and the filtering rules.

The golden rule

Every catch in lib/ either rethrows or tracks through IssueTrackingService before it returns. Track once, in the layer that swallows the error.

Pick the method by who shows the error

Does anything on screen already tell the user this failed?
├─ No, and the user is waiting on it ────────────→ trackIssue          (service shows a notice)
├─ Yes: error state, inline message, or the caller
│  branches on the null / empty / exception ───────→ trackHandledError   (no notice)
└─ The user is not waiting on it (background) ────→ trackSilentError    (no notice)

Illustrative: HomePageState.onLogoutPressed has no try today, and no code in the template calls trackIssue yet.

// A state action. Nothing on screen reports a failed sign-out, so the service
// shows a notice.
try {
  await _homeStore.logout();
} catch (e, stackTrace) {
  _issueTrackingService.trackIssue(
    e,
    stackTrace: stackTrace,
    context: 'logout',
  );
}

// A store action (HomeStore.loadTodos). The page renders `error` as its own
// error state.
} catch (e, stackTrace) {
  _issueTrackingService.trackHandledError(
    e,
    stackTrace: stackTrace,
    handlingContext: 'load_todos',
  );
  _error = e is DioException ? e.message : e.toString();
}

// Background, best effort.
} catch (e, stackTrace) {
  _issueTrackingService.trackSilentError(
    e,
    stackTrace: stackTrace,
    context: 'warm_image_cache',
  );
}

Every method requires an operation name: context: on trackIssue and trackSilentError, handlingContext: on trackHandledError. It is a short snake_case name and becomes the error_context tag in Sentry, so make it greppable back to the call site.

MethodSignature
trackIssue(Object error, {StackTrace? stackTrace, String? message, required String context, bool notifyOnConnectivityError = false})
trackHandledError(Object error, {StackTrace? stackTrace, required String handlingContext})
trackSilentError(Object error, {StackTrace? stackTrace, required String context})

Pass a message: to trackIssue only when you have something better than the generic text, and take it from LocaleKeys (ADR-0012). Never build the message from e.toString() yourself: the service already shows the raw error outside production and a translated one in production.

What to catch

An API boundary catches everything.

  • A request — a store's load, a use case that returns null — catches with catch (e, stackTrace), no on clause. The generated Retrofit client rethrows a response that fails fromJson (a TypeError) unchanged; on DioException would let it skip the error UI.
  • A rollback or a state restore runs on any failure. Also no on clause: catch (e, stackTrace) when this catch tracks, catch (_) when it only restores and rethrows. Between the optimistic change and the restore, more than the request can throw. If only DioException restores, anything else leaves the screen wrong.
  • on DioException is only for branching on the Dio error type or status code while letting everything else propagate, such as a use case that treats a 404 on delete as done and rethrows the rest.

What you do not handle at the call site

The service filters these, so do not special-case them:

  • offline and timeouts — never reported, and by default no notice, because for a load the connection page covers it. A rollback is the exception: see Optimistic changes;
  • cancelled requests — nothing;
  • 401 — nothing (AuthInterceptor clears the session, and the app returns to login). Add other codes your app handles globally to IssueTrackingService.ignoredStatusCodes;
  • no navigator context yet — reported, no notice.

Only branch on DioExceptionType / status code at the call site when your UI does something different for it (a "not found" empty state, a retry button for timeouts).

Use cases: the five patterns

#PatternOn failureTrack with
1Return nullreturn null;trackHandledError
2Return emptyreturn [];trackHandledError
3Throw a domain exceptionthrow TodoAlreadyExistsException();trackHandledError, then throw
4Guard with callbacksawait onError?.call();whatever the guard's failure is
5Rethrowrethrow;nothing here — the caller that catches tracks
// 1. Return null — the caller branches.
Future<TodoDto?> call(String title) async {
  try {
    return await _dioService.todosProvider.createTodo(
      todoCreateDto: TodoCreateRequestDto(title: title),
    );
  } catch (e, stackTrace) {
    _issueTrackingService.trackHandledError(
      e,
      stackTrace: stackTrace,
      handlingContext: 'create_todo',
    );
    return null;
  }
}

// 3. Domain exception — the caller must react to one specific failure.
} catch (e, stackTrace) {
  _issueTrackingService.trackHandledError(
    e,
    stackTrace: stackTrace,
    handlingContext: 'create_todo',
  );
  if (e is DioException && e.response?.statusCode == HttpStatus.conflict) {
    throw const TodoAlreadyExistsException();
  }
  return null;
}

// 4. Guard with callbacks.
Future<void> runIfAllowed({
  required Future<void> Function() onAllowed,
  Future<void> Function()? onDenied,
}) async {
  if (!await _permissionService.hasAccess()) {
    await onDenied?.call();
    return;
  }
  await onAllowed();
}

When the caller has to tell "not found" from "offline", return a sealed result, not null.

Stores: restore, then rethrow

The pattern for a store action that changes state before or around a request and has no error observable for that failure: an optimistic delete, a toggle, a reorder.

  1. Put the state back, whatever failed.
  2. rethrow. Do not track here.
  3. The state that called the store owns the screen and tracks once: trackIssue, or trackHandledError if it shows its own error UI.

A store shared across screens must not decide what the user sees, and this way the failure is still tracked exactly once.

Illustrative: the todos provider in this repo has no delete endpoint, and homePage_deleteTodoFailed is not a real key.

// HomeStore (@singleton).
@action
Future<void> deleteTodo(String id) async {
  final index = _todos.indexWhere((todo) => todo.id == id);
  if (index == -1) {
    return;
  }
  final removed = _todos.removeAt(index);
  try {
    await _dioService.todosProvider.deleteTodo(id);
  } catch (_) {
    // Any failure, not only DioException: the row has to come back. min():
    // the list may have shrunk while the request was out.
    _todos.insert(min(index, _todos.length), removed);
    rethrow;
  }
}

// HomePageState.
@action
Future<void> onTodoDismissed(String id) async {
  try {
    await _homeStore.deleteTodo(id);
  } catch (e, stackTrace) {
    _issueTrackingService.trackIssue(
      e,
      stackTrace: stackTrace,
      context: 'delete_todo',
      message: LocaleKeys.homePage_deleteTodoFailed.tr(),
      notifyOnConnectivityError: true,
    );
  }
}

A store whose page already renders its error observable is different: it restores, sets error, tracks with trackHandledError, and does not rethrow.

Optimistic changes: notify on rollback

"Connectivity errors show no notice" is right for a load: the connection page says the device is offline. It is wrong for an optimistic change the app undid. The deleted row reappears, the connection page explains nothing, and a timeout can happen while the device is online. The user has to be told the change did not stick.

So the catch that tracks a rollback passes notifyOnConnectivityError: true to trackIssue, with a LocaleKeys message that says what was undone, as onTodoDismissed does above. The notice then shows for offline and timeout errors too. They are still not reported to Sentry. Cancellations and ignored status codes still show nothing.

Stores and states

  • A store action that sets an error observable the page renders uses trackHandledError and does not rethrow.
  • A store action with no error observable for the failure restores its state and rethrows (above).
  • A state action with no error UI of its own uses trackIssue so the user sees something. With its own error UI, trackHandledError.
  • LoadingState / isLoading is reset in finally, never only on the success path — a failure must not leave a permanent spinner.
  • Initialization that fails resets its flags, so a retry starts clean:
Future<void> init() async {
  try {
    await _performInitialization();
    _isInitialized = true;
  } catch (e, stackTrace) {
    _isInitialized = false;
    _issueTrackingService.trackHandledError(
      e,
      stackTrace: stackTrace,
      handlingContext: 'player_init',
    );
  }
}

If the caller must know it failed, reset the flag and rethrow instead of tracking, and let the caller track.

Async work

  • No bare unawaited(future) for anything that can fail. Either await it inside a try, or attach the handler:
unawaited(
  _syncService.sync().catchError((Object e, StackTrace stackTrace) {
    _issueTrackingService.trackSilentError(
      e,
      stackTrace: stackTrace,
      context: 'background_sync',
    );
  }),
);
  • Platform channels throw PlatformException. Catch it at the service that owns the channel, track, and rethrow or return a failure value.

Recovery

  • Retry with backoff for idempotent requests; track only when the last attempt fails.
  • Fallback to cached data: track with trackHandledError, return the cache.
  • State reset: put the store back into a state the page can render (error state, previous data), then track, or rethrow and let the caller track (restore, then rethrow).

Testing

Mock IssueTrackingService and verify the call with its exact arguments — its filtering is already covered by test/core/services/issue_tracking_service_test.dart.

class _MockIssueTrackingService extends Mock implements IssueTrackingService {}

test('should return null and report the failure as handled', () async {
  final error = DioException(requestOptions: RequestOptions(path: '/todos'));
  when(
    () => todosProvider.createTodo(todoCreateDto: any(named: 'todoCreateDto')),
  ).thenThrow(error);

  expect(await createTodo('Milk'), isNull);
  verify(
    () => issueTracking.trackHandledError(
      error,
      stackTrace: any(named: 'stackTrace'),
      handlingContext: 'create_todo',
    ),
  ).called(1);
});

When the page shows its own error, also verifyNever on trackIssue — that is the double-message guard. List every named argument, any(named: …) for the ones you do not care about, so the guard matches every call:

verifyNever(
  () => issueTracking.trackIssue(
    any(),
    stackTrace: any(named: 'stackTrace'),
    message: any(named: 'message'),
    context: any(named: 'context'),
    notifyOnConnectivityError: any(named: 'notifyOnConnectivityError'),
  ),
);

For trackIssue, verify context: and message: as values. A rollback test uses a timeout, which is exactly the case the notice flag exists for. Initialise localization in setUpAll(initLocalizationForTests) (test/helpers/pump_app.dart) so .tr() returns the real text:

test('should notify, even on a timeout, when a delete rolls back', () async {
  final error = DioException(
    requestOptions: RequestOptions(path: '/todos/2'),
    type: DioExceptionType.receiveTimeout,
  );
  when(() => homeStore.deleteTodo('2')).thenThrow(error);

  await state.onTodoDismissed('2');

  verify(
    () => issueTracking.trackIssue(
      error,
      stackTrace: any(named: 'stackTrace'),
      context: 'delete_todo',
      message: LocaleKeys.homePage_deleteTodoFailed.tr(),
      notifyOnConnectivityError: true,
    ),
  ).called(1);
});

The store half is tested on its own: after a failed request the row is back at its index, and nothing was tracked (verifyNever on all three methods), because the store rethrows.

Anti-patterns

  • ❌ Empty catch, or catch (_) { return null; } without tracking.
  • ❌ Tracking and rethrowing in the same catch → reported twice.
  • ❌ trackIssue from a store whose page already renders the error → two messages.
  • ❌ on DioException at an API boundary → a response that fails fromJson skips the error UI.
  • ❌ A rollback in on DioException catch → any other failure leaves the optimistic change in place.
  • ❌ A rollback path without notifyOnConnectivityError: true → an offline or timed-out change silently reappears.
  • ❌ A shared store calling trackIssue for a failure it rolled back → the store decides UI for every screen that uses it. Restore and rethrow.
  • ❌ Sentry.captureException called directly outside the service.
  • ❌ Attaching request headers or bodies to a report (tokens, user data).
  • ❌ throw Exception('Internal server error 500') — throw a typed domain exception; user-facing text comes from LocaleKeys.

Checklist

  • Every catch tracks or rethrows — never both, never neither
  • Method matches who shows the error (issue / handled / silent)
  • Every call passes its operation name: context: on trackIssue / trackSilentError, handlingContext: on trackHandledError
  • API boundaries, rollbacks and restores catch everything (no on clause); on DioException only to branch on the Dio error type
  • A store with no error observable for the failure restores and rethrows; the calling state tracks
  • A rollback's trackIssue passes a LocaleKeys message and notifyOnConnectivityError: true
  • Loading flags reset in finally; init flags reset on failure
  • No bare unawaited on a future that can fail
  • No direct Sentry calls, no headers or bodies in reports
  • Test verifies the tracking call on the failure path — write-tests

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.