urql: Retry-exchange Operations never resolving in react-native

urql version & exchanges:

"urql": "^2.0.3",
"@urql/exchange-retry": "^0.3.0",
"react-native": "~0.63.4"
"expo": "~41.0.1"

Steps to reproduce In react-native app:

  1. Setup queries to fail and be retried with retryExhcnage
  2. Fire off query

Expected behavior Query retried maxNumberAttempts times and then resolves

Actual behavior

  • Query never resolves (error never received by hook or manual caller)
  • Retry query never fired (iOS + sometimes android)

Demo: https://snack.expo.dev/@git/github.com/Mookiies/request-policy-demo-native change expo to 41.0.0 in bottom bar Screen Shot 2021-09-07 at 5 07 13 PM

In this demo the toggle todos button will cause text to render when an error comes back from a useQuery and showing loading until then. It is common for it to get stuck in loading and never receive an error. This happens basically every time ios, sometimes android, never web (all same expo snack code).

Commenting out the retry exchange causes this problem to go away. When running locally with debugExchange you can see that operations stop passing between fetch and retry exchanges.

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 1
  • Comments: 23 (16 by maintainers)

Most upvoted comments

I dove a bit deeper into this after your comment and started noticing that the delay seemed to be causing this, removing it made the issue go away. I’ve attempted changing this to a static amount, 500 & 1000 but to no avail, it seems that the main issue is just the delay function itself 😅 when looking at the implementation of delay I couldn’t find anything to explain this issue as it just leverages setTimeout

Hmm might be related:

@bryansum It’s nothing fancy I just took the code from the exchange here: https://github.com/FormidableLabs/urql/blob/main/exchanges/retry/src/retryExchange.ts and replaced the delay function on line 99 with a custom one using background timers. I copy-pasted the native js (compiled and optimized) function I found in node_modules/wonka/dist/wonka.js.

You could also look at the more “high level” one in node_modules/wonka/src/web/WonkaJs.bs.js along with the type definition in node_modules/wonka/src/web/WonkaJs.gen.tsx, but that one has a dependency on a lib called bs-platform.

Here is the code I got

import {
  makeSubject,
  share,
  pipe,
  merge,
  filter,
  fromValue,
  delay,
  mergeMap,
  takeUntil,
} from "wonka";
import {
  makeOperation,
  Exchange,
  Operation,
  CombinedError,
  OperationResult,
} from "@urql/core";
import { sourceT } from "wonka/dist/types/src/Wonka_types.gen";
import BackgroundTimer from "react-native-background-timer";

export interface RetryExchangeOptions {
  initialDelayMs?: number;
  maxDelayMs?: number;
  randomDelay?: boolean;
  maxNumberAttempts?: number;
  /** Conditionally determine whether an error should be retried */
  retryIf?: (error: CombinedError, operation: Operation) => boolean;
  /** Conditionally update operations as they're retried (retryIf can be replaced with this) */
  retryWith?: (
    error: CombinedError,
    operation: Operation
  ) => Operation | null | undefined;
}

const customDelay = (a: number) => {
  return function (b) {
    return function (c) {
      let e = 0;
      return b(function (b) {
        "number" == typeof b || b.tag
          ? ((e = (e + 1) | 0),
            BackgroundTimer.setTimeout(function (a) {
            0 !== e && ((e = (e - 1) | 0), c(b));
          }, a))
          : c(b);
      });
    };
  };
};

export const retryExchange = ({
  initialDelayMs,
  maxDelayMs,
  randomDelay,
  maxNumberAttempts,
  retryIf,
  retryWith,
}: RetryExchangeOptions): Exchange => {
  const MIN_DELAY = initialDelayMs || 1000;
  const MAX_DELAY = maxDelayMs || 15000;
  const MAX_ATTEMPTS = maxNumberAttempts || 2;
  const RANDOM_DELAY = randomDelay || true;

  return ({ forward, dispatchDebug }) =>
    ops$ => {
      const sharedOps$ = pipe(ops$, share);
      const { source: retry$, next: nextRetryOperation } =
        makeSubject<Operation>();

      const retryWithBackoff$ = pipe(
        retry$,
        mergeMap((op: Operation) => {
          const { key, context } = op;
          const retryCount = (context.retryCount || 0) + 1;
          let delayAmount = context.retryDelay || MIN_DELAY;

          const backoffFactor = Math.random() + 1.5;
          // if randomDelay is enabled and it won't exceed the max delay, apply a random
          // amount to the delay to avoid thundering herd problem
          if (RANDOM_DELAY && delayAmount * backoffFactor < MAX_DELAY) {
            delayAmount *= backoffFactor;
          }

          // We stop the retries if a teardown event for this operation comes in
          // But if this event comes through regularly we also stop the retries, since it's
          // basically the query retrying itself, no backoff should be added!
          const teardown$ = pipe(
            sharedOps$,
            filter(op => {
              return (
                (op.kind === "query" || op.kind === "teardown") &&
                op.key === key
              );
            })
          );

          dispatchDebug({
            type: "retryAttempt",
            message: `The operation has failed and a retry has been triggered (${retryCount} / ${MAX_ATTEMPTS})`,
            operation: op,
            data: {
              retryCount,
            },
          });

          // Add new retryDelay and retryCount to operation
          return pipe(
            fromValue(
              makeOperation(op.kind, op, {
                ...op.context,
                retryDelay: delayAmount,
                retryCount,
              })
            ),
            customDelay(delayAmount),
            // Stop retry if a teardown comes in
            takeUntil(teardown$)
          );
        })
      );

      const result$ = pipe(
        merge([sharedOps$, retryWithBackoff$]),
        forward,
        share,
        filter(res => {
          // Only retry if the error passes the conditional retryIf function (if passed)
          // or if the error contains a networkError
          if (
            !res.error ||
            (retryIf
              ? !retryIf(res.error, res.operation)
              : !retryWith && !res.error.networkError)
          ) {
            return true;
          }

          const maxNumberAttemptsExceeded =
            (res.operation.context.retryCount || 0) >= MAX_ATTEMPTS - 1;

          if (!maxNumberAttemptsExceeded) {
            const operation = retryWith
              ? retryWith(res.error, res.operation)
              : res.operation;
            if (!operation) return true;

            // Send failed responses to be retried by calling next on the retry$ subject
            // Exclude operations that have been retried more than the specified max
            nextRetryOperation(operation);
            return false;
          }

          dispatchDebug({
            type: "retryExhausted",
            message:
              "Maximum number of retries has been reached. No further retries will be performed.",
            operation: res.operation,
          });

          return true;
        })
      ) as sourceT<OperationResult>;

      return result$;
    };
};

You can see the custom delay function on line 36 and where it is called on line 116 🙂

Yeh, the weird thing is this doesn’t seem to happen on web at all so I’m thinking it has something to do with how setTimeout/ticks are handled in React-Native