Repository: igorkamyshev/farfetched (ts)

Issue:
supressIntermediateErrors does not work for contract failure
`supressIntermediateErrors` in `retry` works well for all kinds of errors except contract failure.
I guess behavior should be the same for all kinds of errors, otherwise it's counterintuitive.

We can see, that request marked as failed after every retry

[Error reproduction](https://codesandbox.io/p/sandbox/vue3-forked-yxjdyz?layout=%257B%2522sidebarPanel%2522%253A%2522EXPLORER%2522%252C%2522rootPanelGroup%2522%253A%257B%2522direction%2522%253A%2522horizontal%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522id%2522%253A%2522ROOT_LAYOUT%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522clu9gsnjm00073b6i8o0mnjb3%2522%252C%2522sizes%2522%253A%255B100%252C0%255D%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522EDITOR%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522id%2522%253A%2522clu9gsnjm00033b6inthek3g9%2522%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522SHELLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522id%2522%253A%2522clu9gsnjm00043b6ipvpwqwhg%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522DEVTOOLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522id%2522%253A%2522clu9gsnjm00063b6iu1w8gqam%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%252C%2522sizes%2522%253A%255B50%252C50%255D%257D%252C%2522tabbedPanels%2522%253A%257B%2522clu9gsnjm00033b6inthek3g9%2522%253A%257B%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clu9gsnjm00023b6iy88pogx2%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522FILE%2522%252C%2522filepath%2522%253A%2522%252Fsrc%252FApp.vue%2522%252C%2522state%2522%253A%2522IDLE%2522%252C%2522initialSelections%2522%253A%255B%257B%2522startLineNumber%2522%253A37%252C%2522startColumn%2522%253A4%252C%2522endLineNumber%2522%253A37%252C%2522endColumn%2522%253A4%257D%255D%257D%255D%252C%2522id%2522%253A%2522clu9gsnjm00033b6inthek3g9%2522%252C%2522activeTabId%2522%253A%2522clu9gsnjm00023b6iy88pogx2%2522%257D%252C%2522clu9gsnjm00063b6iu1w8gqam%2522%253A%257B%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clu9gsnjm00053b6ioom2j54g%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522UNASSIGNED_PORT%2522%252C%2522port%2522%253A0%252C%2522path%2522%253A%2522%252F%2522%257D%255D%252C%2522id%2522%253A%2522clu9gsnjm00063b6iu1w8gqam%2522%252C%2522activeTabId%2522%253A%2522clu9gsnjm00053b6ioom2j54g%2522%257D%252C%2522clu9gsnjm00043b6ipvpwqwhg%2522%253A%257B%2522tabs%2522%253A%255B%255D%252C%2522id%2522%253A%2522clu9gsnjm00043b6ipvpwqwhg%2522%257D%257D%252C%2522showDevtools%2522%253Atrue%252C%2522showShells%2522%253Afalse%252C%2522showSidebar%2522%253Atrue%252C%2522sidebarPanelSize%2522%253A15%257D)

Pull request:
fix bug in `retry` with `contract`
fixes #459
creates #476

Patch:
diff --git a/.changeset/fuzzy-cooks-join.md b/.changeset/fuzzy-cooks-join.md
new file mode 100644
index 0000000..5afad7c
--- /dev/null
+++ b/.changeset/fuzzy-cooks-join.md
@@ -0,0 +1,5 @@
+---
+"@farfetched/core": patch
+---
+
+Fix bug with extra fires of `finished.failure` in case of failed contract application with `retry` operator
diff --git a/packages/core/package.json b/packages/core/package.json
index c9e7c8b..cdeb14c 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -41,7 +41,7 @@
   "size-limit": [
     {
       "path": "./dist/core.js",
-      "limit": "16.1 kB"
+      "limit": "16.2 kB"
     }
   ]
 }
diff --git a/packages/core/src/remote_operation/create_remote_operation.ts b/packages/core/src/remote_operation/create_remote_operation.ts
index 045cab3..e216587 100644
--- a/packages/core/src/remote_operation/create_remote_operation.ts
+++ b/packages/core/src/remote_operation/create_remote_operation.ts
@@ -152,6 +152,11 @@ export function createRemoteOperation<
     error: Error | InvalidDataError;
     meta: ExecutionMeta;
   }>();
+  const failedIgnoreSuppression = createEvent<{
+    params: Params;
+    error: Error | InvalidDataError;
+    meta: ExecutionMeta;
+  }>();
   const aborted = createEvent<{
     params: Params;
     meta: ExecutionMeta;
@@ -283,7 +288,10 @@ export function createRemoteOperation<
     fn: ({ params, result }) => ({
       params: params.params,
       result: result.result as Data,
-      meta: { stopErrorPropagation: false, stale: result.stale },
+      meta: {
+        stopErrorPropagation: result.stopErrorPropagation ?? false,
+        stale: result.stale,
+      },
     }),
     filter: $enabled,
     target: applyContractFx,
@@ -373,6 +381,17 @@ export function createRemoteOperation<
     target: failedNoFilters,
   });
 
+  sample({
+    clock: applyContractFx.fail,
+    fn: ({ error, params }) => ({
+      error,
+      // Extract original params, it is params of params
+      params: params.params,
+      meta: params.meta,
+    }),
+    target: failedIgnoreSuppression,
+  });
+
   sample({
     clock: invalidDataRecieved,
     filter: ({ meta }) => !meta.stopErrorPropagation,
@@ -387,6 +406,19 @@ export function createRemoteOperation<
     target: failedNoFilters,
   });
 
+  sample({
+    clock: invalidDataRecieved,
+    fn: ({ params, validation, meta, result }) => ({
+      params,
+      error: invalidDataError({
+        validationErrors: unwrapValidationResult(validation),
+        response: result,
+      }),
+      meta,
+    }),
+    target: failedIgnoreSuppression,
+  });
+
   // Emit skip for disabling in-flight operation
   sample({
     clock: $enabled.updates,
@@ -463,6 +495,7 @@ export function createRemoteOperation<
         pushData,
         startWithMeta,
         callObjectCreated,
+        failedIgnoreSuppression,
       },
     },
   };
@@ -475,7 +508,7 @@ function createDataSourceHandlers<Params>(dataSources: DataSource<Params>[]) {
       skipStale?: boolean;
       meta: ExecutionMeta;
     },
-    { result: unknown; stale: boolean },
+    { result: unknown; stale: boolean; stopErrorPropagation?: boolean },
     { stopErrorPropagation: boolean; error: unknown }
   >({
     handler: async ({ params, skipStale }) => {
diff --git a/packages/core/src/remote_operation/type.ts b/packages/core/src/remote_operation/type.ts
index 5fe900e..ea9a599 100644
--- a/packages/core/src/remote_operation/type.ts
+++ b/packages/core/src/remote_operation/type.ts
@@ -122,6 +122,11 @@ export interface RemoteOperation<
       pushError: EventCallable<Error>;
       startWithMeta: EventCallable<{ params: Params; meta: ExecutionMeta }>;
       callObjectCreated: Event<CallObject>;
+      failedIgnoreSuppression: Event<{
+        params: Params;
+        error: Error;
+        meta: ExecutionMeta;
+      }>;
     } & ExtraLowLevelAPI;
     experimentalAPI?: {
       attach: <Source, NewParams>(config: {
diff --git a/packages/core/src/retry/retry.ts b/packages/core/src/retry/retry.ts
index 6a785d5..adbdf0a 100644
--- a/packages/core/src/retry/retry.ts
+++ b/packages/core/src/retry/retry.ts
@@ -160,6 +160,11 @@ export function retry<
     const originalFx =
       operation.__.lowLevelAPI.dataSourceRetrieverFx.use.getCurrent();
 
+    sample({
+      clock: operation.__.lowLevelAPI.failedIgnoreSuppression,
+      target: failed,
+    });
+
     operation.__.lowLevelAPI.dataSourceRetrieverFx.use(
       attach({
         source: { supressError: $supressError, partialFilter: $partialFilter },
@@ -182,7 +187,7 @@ export function retry<
           try {
             const result = await originalFx(opts);
 
-            return result;
+            return { ...result, stopErrorPropagation: supressError };
           } catch (error: any) {
             const failInfo = {
               params: opts.params,


Test patch:
diff --git a/packages/core/src/retry/__tests__/retry.test.ts b/packages/core/src/retry/__tests__/retry.test.ts
index ea49537..2e3e667 100644
--- a/packages/core/src/retry/__tests__/retry.test.ts
+++ b/packages/core/src/retry/__tests__/retry.test.ts
@@ -4,6 +4,8 @@ import { allSettled, createWatch, fork } from 'effector';
 import { unknownContract } from '../../contract/unknown_contract';
 import { createJsonMutation } from '../../mutation/create_json_mutation';
 import { httpError } from '../../errors/create_error';
+import { createJsonQuery } from '../../query/create_json_query';
+import { fetchFx } from '../../fetch/fetch';
 import { retry } from '../retry';
 
 describe('retry', () => {
@@ -48,4 +50,37 @@ describe('retry', () => {
 
     expect(onFailure).toBeCalledTimes(1);
   });
+
+  test('does supress contract failure error as well, issue #459', async () => {
+    const q = createJsonQuery({
+      request: { url: 'https://api.salo.com', method: 'GET' },
+      response: {
+        contract: {
+          isData(v): v is unknown {
+            return false;
+          },
+          getErrorMessages(v) {
+            return ['Failed contract'];
+          },
+        },
+      },
+    });
+
+    retry(q, { times: 1, delay: 1 });
+
+    const scope = fork({
+      handlers: [[fetchFx, () => new Response('{"data": "ok"}')]],
+    });
+
+    const failedListener = vi.fn();
+    createWatch({ unit: q.finished.failure, scope, fn: failedListener });
+
+    const startedListener = vi.fn();
+    createWatch({ unit: q.started, scope, fn: startedListener });
+
+    await allSettled(q.start, { scope });
+
+    expect(startedListener).toBeCalledTimes(2 /* Initial and retry */);
+    expect(failedListener).toBeCalledTimes(1 /* Only latest failure */);
+  });
 });