Skip to content

Cannot cancel a stream that already has a reader #3971

Open
@jdnichollsc

Description

@jdnichollsc

What version of Remix are you using?

1.6.7

Steps to Reproduce

I'm executing a http request from a route Action but I'm getting this error because Server is busy (not responding):

[frontend] /frontend/node_modules/web-streams-polyfill/src/lib/readable-stream.ts:149
[frontend]       return promiseRejectedWith(new TypeError('Cannot cancel a stream that already has a reader'));
[frontend]                                  ^
[frontend] TypeError: Cannot cancel a stream that already has a reader
[frontend]     at ReadableStream.cancel (/frontend/node_modules/web-streams-polyfill/src/lib/readable-stream.ts:149:34)
[frontend]     at abort (/frontend/node_modules/@remix-run/web-fetch/src/fetch.js:75:18)
[frontend]     at AbortSignal.abortAndFinalize (/frontend/node_modules/@remix-run/web-fetch/src/fetch.js:91:4)
[frontend]     at AbortSignal.[nodejs.internal.kHybridDispatch] (node:internal/event_target:643:20)
[frontend]     at AbortSignal.dispatchEvent (node:internal/event_target:585:26)
[frontend]     at abortSignal (node:internal/abort_controller:284:10)
[frontend]     at AbortController.abort (node:internal/abort_controller:315:5)
[frontend]     at Timeout._onTimeout (/frontend/apps/webapp/app/utils/http.ts:16:42)
[frontend]     at listOnTimeout (node:internal/timers:559:17)
[frontend]     at processTimers (node:internal/timers:502:7)

I created a simple utility to support timeout with fetch:

const DEFAULT_TIMEOUT = 5000;

export type HttpOptions = RequestInit & {
  timeout?: number;
  defaultResponse?: unknown;
  parseJSON?: boolean;
}

async function httpRequest(
  resource: string,
  options: HttpOptions = { timeout: DEFAULT_TIMEOUT, parseJSON: true },
) {
  const { timeout, ...rest } = options;
  
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), timeout || DEFAULT_TIMEOUT);
  try {
    const response = await fetch(resource, {
      ...rest,
      signal: controller.signal
    });
    clearTimeout(id);
    return rest.parseJSON === false ? response : response.json();
  } catch (error) {
    if (rest.defaultResponse !== undefined) {
      return rest.defaultResponse;
    }
    if (controller.signal.aborted) {
      throw new Error('Server is busy, please try again');
    }
    throw error;
  }
}

export { httpRequest };

Then I'm using that method to execute a POST request:

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();

  const email = formData.get('email');
  try {
    await httpRequest(`${apiURL}/auth`, {
      method: 'POST',
      body: JSON.stringify({
        email,
      }),
      parseJSON: false,
    });

    // ...
  } catch (error) {
    return {
      error: (error as Error)?.message,
    }
  }
};

Also I'm already using CatchBoundary and ErrorBoundary for this page:

function renderLoginPage(error?: Error) {
  const data = useLoaderData();
  return (
    <div id="main-content" className="relative pb-36">
      <LoginPage {...data} error={error} />
    </div>
  );
}

export const CatchBoundary = () => {
  const caught = useCatch();
  return renderLoginPage(caught.data);
};

export const ErrorBoundary: ErrorBoundaryComponent = ({ error }) => {
  return renderLoginPage(error);
};

export default function () {
  return renderLoginPage();
}

Expected Behavior

Be able to get the error message using useActionData Hook from Remix, it's working for other validations before executing the HTTP request.

Actual Behavior

Getting an unexpected exception:

Remix error

Thanks for your help! <3

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions