remix icon indicating copy to clipboard operation
remix copied to clipboard

Server Timeout on deferred routes

Open keepyara opened this issue 1 year ago • 18 comments

What version of Remix are you using?

2.2.0

Are all your remix dependencies & dev-dependencies using the same version?

  • [X] Yes

Steps to Reproduce

Follow up from this issue: https://github.com/remix-run/remix/issues/4493

  1. Create a remix project with @vercel/remix @supabase/auth-helpers-remix and tailwindcss

  2. Create a route with a loader deferring results from supabase

  3. Add cache control headers

  4. Make changes to tailwind and save

  5. Refresh the route (a couple of times to trigger the timeout)

Expected Behavior

Expected to have deferred or cached result to come through.

Actual Behavior

After refreshing the page you will experience a Server timeout error from the error boundary or Await's errorElement.

keepyara avatar Nov 13 '23 12:11 keepyara

Are you able to provide a minimal reproduction? Ideally without the supabase requirement and just using promises to return deferred data. Vercel does some tricky things with their integration points so it's hard to know if this s a Remix issue or a Vercel adapter issue.

brophdawg11 avatar Nov 13 '23 16:11 brophdawg11

@brophdawg11 Yes, here is a repo: https://github.com/yarapolana/reproductible-example

For full context, I think Sentry should be included as I've seen that could be related to this issue. https://github.com/getsentry/sentry-javascript/issues/7332

keepyara avatar Nov 13 '23 21:11 keepyara

Yeah encountering same issue over here w prisma

taochu avatar Dec 06 '23 11:12 taochu

Seeing this intermittently as well.

chopfitzroy avatar Feb 25 '24 01:02 chopfitzroy

same here, i was just testing out remix

i deferred a setTimeout promise and the server times out in the client

actually resolves the promise but can't swap the UI

vitormarkis avatar Feb 26 '24 00:02 vitormarkis

@keepyara What am I supposed to do in that reproduction? There's a handful of different routes all using suspense - all of which are working OK for me - but the styles don't seem to be applying so I can't do the Make changes to tailwind and save step above.

Could you further simplify that by removing the turborepo stuff and just start with a bare-bones Remix app with a single route exhibiting the problem? And then provide the steps to trigger the problem?

brophdawg11 avatar Feb 27 '24 21:02 brophdawg11

@brophdawg11 Thats great that my repo works on your side, I will minimize the repo for you to retry the issue, though the instructions in the original comment are clear which fall into the "bare-bones" project that you describe.

yarapolana avatar Feb 29 '24 10:02 yarapolana

It only happens if the deferred data takes a sufficiently long time (5 seconds, in my testing).

@brophdawg11 Here you go, bro(ph):

https://stackblitz.com/edit/remix-run-remix-ozkevh?file=app%2Froutes%2Fdefer.tsx

wKovacs64 avatar Mar 02 '24 22:03 wKovacs64

That was my original hunch but the reproducible example only deferred for 3 seconds so I assumed that was not the cause 🤷

If this is indeed the root cause for OP, you control the length of this timeout via the <RemixServer abortDelay> prop which defaults to 5 seconds in entry.server.tsx. If you don't have an app/entry.server.tsx fiel, you can run npx remix reveal entry.server to create it and then make the edit accordingly

brophdawg11 avatar Mar 04 '24 16:03 brophdawg11

@brophdawg11 This was my initial solution but exposing the entry.server.tsx breaks the application when using Vercel.

yarapolana avatar Mar 04 '24 16:03 yarapolana

Have you filed an issue on the Vercel side? I'm not sure what Remix can do to fix that if Vercel doesn't let you access the entry.server in their deployment setup?

brophdawg11 avatar Mar 05 '24 16:03 brophdawg11

I ended up updating remix libs to 2.8.0 and this hasn't come up yet, no changes were made besides this. Will close this once I get to the account that opened the issue.

yarapolana avatar Mar 05 '24 20:03 yarapolana

I'm getting this error as well, on 2.8.0. When I save changes to my project and the page reloads (or if I do a manual refresh), I always get the "Server timeout" error on routes that fetches from a 3rd party api. ABORT_DELAY is set to 20_000 just for testing. After navigating to another route and then back again, it starts working. Edit: both when running locally and in azure cloud.

stellan-s avatar Mar 06 '24 13:03 stellan-s

HEllo, i have the same issue ⬆️

with :

├── @remix-run/[email protected]
├── @remix-run/[email protected]
├── @remix-run/[email protected]
├── @remix-run/[email protected]
├── @remix-run/[email protected]
├── @remix-run/[email protected]
├── @tailwindcss/[email protected]
├── @types/[email protected]
├── @types/[email protected]
├── @types/[email protected]
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]

guelosuperstart avatar Mar 06 '24 20:03 guelosuperstart

We are facing same issue when defer fetches third party data, probably related to timeout as mentioned by some.

ai-niket avatar Apr 05 '24 20:04 ai-niket

Can confirm issue still exists when you reload a page with streaming. If you navigate from a different page to this page it works well but If you refresh this page it throws time out error. Opening this page link on a different tab throws error

here is my example

export async function loader() {
  const getData = async () => {
    await new Promise((resolve) => setTimeout(resolve, 1000));
    return {
      name: "Product name",
      image: `https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQo4sBM1qm7OEiInjW5oZVF4q67s-HxnVzRL1kEagILpQ&s`,
    };
  };

  const query = getData();

  return defer({
    query,
  });
}

export default function Test() {
  const { query } = useLoaderData<typeof loader>();
  return (
    <div>
      <h1>Test</h1>

      <Suspense fallback={<p>Hey cool streaming on remix</p>}>
        <Await resolve={query}>
          {(data) => (
            <>
              <h2>{data?.name}</h2>
              <Image src={data?.image} alt={data?.name} />
            </>
          )}
        </Await>
      </Suspense>
    </div>
  );
}

rnwonder avatar Apr 12 '24 20:04 rnwonder

I'm also experiencing the same issue with deferred data. I tried with higher ABORT_DELAY value but nothing changed.

abhishek-prabhakar avatar Apr 20 '24 17:04 abhishek-prabhakar

this error is making the whole streaming feature redundant imho.

ItamarShDev avatar Apr 25 '24 11:04 ItamarShDev

Remix 2.6.0 still has this issue

thuupx avatar May 10 '24 02:05 thuupx

We have the same issue are there any plans to come over it ? This issue is open since November 2023 isn't it slowly time to solve it ?

Tjerk-Haaye-Henricus avatar May 10 '24 09:05 Tjerk-Haaye-Henricus

Can someone clarify what needs to be solved here?

The answer for extending the streaming timeout is to use <RemixServer abortDelay>. OP's issue was since resolved and was likely an issue with configuration/setup on Vercel or package deps mismatches. The only other reproduction doesn't produce any errors for me on a local app.

Can someone provide a working minimal reproduction and clear steps to trigger the issue (via stackblitz or a github repo)? Otherwise I will close this out in a few days since the OP issue has been resolved.

brophdawg11 avatar May 10 '24 13:05 brophdawg11

In my case the abortdelay was indeed set too low. The confusion was that a timeout wasn't triggered if I navigated to another route and then back again, causing me to believe that a navigation "fixed" the issue. Not very easy to explain.

What version of Remix are you using?

2.9.1

Are all your remix dependencies & dev-dependencies using the same version?

Yes

Steps to reproduce

  1. If I set the ABORT_DELAY to 5000
const ABORT_DELAY = 5_000;

  1. and then create two routes like this:
import { defer } from "@remix-run/node";
import { useLoaderData, Await } from "@remix-run/react";
import { Suspense } from "react";

export async function loader() {
  const query = fetch('https://mocki.io/v1/a6b5ff21-0aa1-455a-8101-ac2a2b1cc206',
    {
      method: "GET",
      headers: {
      },
      redirect: "follow",
    },)
    .then(res => {
      return new Promise((resolve) => {
       // a delay here...
        setTimeout(() => {
          resolve(res)
        }, 12000)
      })
    })
    .then(res => res.json())
  return defer({
    query,
  });
}

export default function UnCool() {
  const { query } = useLoaderData<typeof loader>();
  return (
    <div>
      <h1>Test 123</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <Await resolve={query}>
          {(data) => (
            <>
              <h2>{data?.name}</h2>
              {JSON.stringify(data)}
            </>
          )}
        </Await>
      </Suspense>
    </div>
  );
}

and this:

import { defer, json } from "@remix-run/node";
export async function loader() {
  return json({
    message: "OK",
  });
}

export default function Cool() {
  return (
    <div>
      <h1>Everything is cool here</h1>
    </div>
  );
}
  1. and then navigate to '/uncool' I will get a timeout. No surprises there.
  2. While the timeout error message is displayed, push the browsers back button (or click a link in a real world example) and then go forward again, back to the "uncool" route.

Expected behaviour

Another timeout

Actual behaviour

No timeout is triggered. The page loads until the promise resolves and the data is displayed.

Bonus

refresh the uncool route and the timeout is triggered again.

stellan-s avatar May 10 '24 15:05 stellan-s

Can someone clarify what needs to be solved here?

The answer for extending the streaming timeout is to use <RemixServer abortDelay>. OP's issue was since resolved and was likely an issue with configuration/setup on Vercel or package deps mismatches. The only other reproduction doesn't produce any errors for me on a local app.

Can someone provide a working minimal reproduction and clear steps to trigger the issue (via stackblitz or a github repo)? Otherwise I will close this out in a few days since the OP issue has been resolved.

Hello there thanks for taking the time to look at this. If you scroll up you will see my code example of the issue.

When you add streaming to a page and navigate to the page say via a Link component it streams perfectly. But when you try refreshing the page you get a timeout error. If you copy the page url and open in a different tab you get timeout error as well.

Currently the streaming as implemented in the code snippet I posted above only works when you navigate to the page from another page.

rnwonder avatar May 10 '24 15:05 rnwonder

ok - this looks like it's just a bit of confusion around what abortDelay does. abortDelay is strictly about the initial server-side render of an HTML document. It's used for 2 things:

  1. Aborting the renderToPipeableStream call
  2. Setting a timeout to automatically reject pending streaming promises in the initial document render via <RemixServer>

renderToPipeableStream and <RemixServer> are out of the picture after the initial document render, so that abortDelay value has no impact on subsequent client side navigations that load deferred data through ?_data requests. Those can take as long as they want (until your server/browser cancels any open connection) and still resolve.

So the answer is to set your abortDelay value accordingly for HTML document renders. If you are getting timeouts, increase the value.

The upcoming (currently unstable) Single Fetch feature replaces deprecates defer with a more powerful streaming format which gives you a new timeout mechanism that works the same on both document and data requests, so I would recommend updating to that when it lands as stable (or before if you want!) to normalize these behaviors.

brophdawg11 avatar May 10 '24 17:05 brophdawg11

ok - this looks like it's just a bit of confusion around what abortDelay does. abortDelay is strictly about the initial server-side render of an HTML document. It's used for 2 things:

  1. Aborting the renderToPipeableStream call
  2. Setting a timeout to automatically reject pending streaming promises in the initial document render via <RemixServer>

renderToPipeableStream and <RemixServer> are out of the picture after the initial document render, so that abortDelay value has no impact on subsequent client side navigations that load deferred data through ?_data requests. Those can take as long as they want (until your server/browser cancels any open connection) and still resolve.

So the answer is to set your abortDelay value accordingly for HTML document renders. If you are getting timeouts, increase the value.

The upcoming (currently unstable) Single Fetch feature replaces deprecates defer with a more powerful streaming format which gives you a new timeout mechanism that works the same on both document and data requests, so I would recommend updating to that when it lands as stable (or before if you want!) to normalize these behaviors.

This definitely put things in perspective thank you!

However, I believe the default timeout duration is 5s right? Last time I tested I believe I also increased the timeout to 10s after finding this pr but still got a timeout error.

If you would refer to the code snippet I used above the promise call being deferred was delayed by only 1s which should be enough time if I am not mistaken for it to be resolved before the timeout duration of 5s.

That's where the issue was for me at least.

rnwonder avatar May 11 '24 04:05 rnwonder

@brophdawg11

Finally had the time to test this out again. I upgraded to the latest version(2.9.1) and tested again. It works flawlessly now!

rnwonder avatar May 11 '24 20:05 rnwonder

HEllo @rnwonder,

How did you get it to work? I still have the error on my side in my dev environment (local)

All my components are up to date :

├── @remix-run/[email protected]
├── @remix-run/[email protected]
├── @remix-run/[email protected]
├── @remix-run/[email protected]
├── @remix-run/[email protected]
├── @remix-run/[email protected]
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]

example :


export async function api1(period?: string): Promise<any> {
    await new Promise(resolve => setTimeout(resolve, 1000));
    return {success : true};
}

export async function loader({ request }: LoaderFunctionArgs) {
  const api1 =  api1(); -> call my external backend with a delay of 1 seconde
  const api2 =  await api2(); call my external backend
  const api3 =  await api3(); call my external backend

  return defer({ api1, api2, api3 });
}

export default function Component() {
  const { user }: { user: UserSession } = useAppLoaderData();
  const { api1, api2, api3 } = useLoaderData<typeof loader>();
    return (
    <>
            <Suspense fallback={'Loading...'}>
            <Await resolve={api1}>
              {(data) =>
                <Component2 data={data} />}
            </Await>
          </Suspense>
    </>
  );
}

if i navigate on my site everything is ok, but if i reload the page i got Server timeout.

In my entry.server : const ABORT_DELAY = 30_000;

i migrated my application to vite.

Thank you for your advice.

guelosuperstart avatar May 13 '24 06:05 guelosuperstart

Yes, the reload (and reload on save) does indeed cause an immediate timeout. Same here.

stellan-s avatar May 14 '24 11:05 stellan-s

@guelosuperstart

Hi there so for me I am not really sure what fixed it because I could have sworn when I increase mine to 10_000 when I tested few weeks back I still got the same timeout error on reload. But upgrading just fixed it.

I just created a new remix project now, added a test route with my example above got the timeout error for the default 5_000 abort delay.

when I increased this to any number above this it works. Maybe try it out on your end. These are the dependencies for the new remix app

  "dependencies": {
    "@remix-run/node": "^2.9.2",
    "@remix-run/react": "^2.9.2",
    "@remix-run/serve": "^2.9.2",
    "isbot": "^4.1.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@remix-run/dev": "^2.9.2",
    "@types/react": "^18.2.20",
    "@types/react-dom": "^18.2.7",
    "@typescript-eslint/eslint-plugin": "^6.7.4",
    "@typescript-eslint/parser": "^6.7.4",
    "eslint": "^8.38.0",
    "eslint-import-resolver-typescript": "^3.6.1",
    "eslint-plugin-import": "^2.28.1",
    "eslint-plugin-jsx-a11y": "^6.7.1",
    "eslint-plugin-react": "^7.33.2",
    "eslint-plugin-react-hooks": "^4.6.0",
    "typescript": "^5.1.6",
    "vite": "^5.1.0",
    "vite-tsconfig-paths": "^4.2.1"
  },

rnwonder avatar May 14 '24 15:05 rnwonder

Hello @rnwonder

I did some tests and even with this simple code, i have a time Server timeout :/ :

import { defer } from '@remix-run/node';
import { Suspense } from 'react';
import { Await, useLoaderData } from '@remix-run/react';

export const loader = async () => {
  const promiseOne = new Promise((resolve) => {
    setTimeout(() => {
      const numDocs = Math.floor(Math.random() * 10);
      resolve({ num: numDocs });
    }, 1000);
  }) as Promise<{ num: number }>;

  return defer({ promiseOne });
};

const TestPage = () => {
  const data = useLoaderData<typeof loader>();

  return (
    <Suspense fallback={<span> {`Loading... `} </span>}>
      <Await resolve={data.promiseOne} errorElement={<p>Error executing job!</p>}>
        {(val) => (<div>{val.num}</div>)}
      </Await>
    </Suspense>
  );
};

export default TestPage;
{
    "@remix-run/express": "^2.9.2",
    "@remix-run/node": "^2.9.2",
    "@remix-run/react": "^2.9.2",
    "@remix-run/serve": "^2.9.2",
    "react-dom": "^18.3.1",
    "remix": "^2.9.2",
},
{
    "@remix-run/dev": "^2.9.2",
    "@types/react": "^18.3.3",
    "@types/react-dom": "^18.3.0",
    "vite": "^5.2.12",
    "vite-tsconfig-paths": "^4.3.2"
}
entry.server.ts
import { PassThrough } from 'node:stream';

import type { AppLoadContext, EntryContext } from '@remix-run/node';
import { createReadableStreamFromReadable } from '@remix-run/node';
import { RemixServer } from '@remix-run/react';
import { isbot } from 'isbot';
import { renderToPipeableStream } from 'react-dom/server';
import { createInstance } from 'i18next';
import { I18nextProvider, initReactI18next } from 'react-i18next';
import i18next from '~/localization/i18n.server';
import i18n from '~/localization/i18n';
import { returnLanguageIfSupported } from '~/localization/resource';

const ABORT_DELAY = 30_000;

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
  // This is ignored so we can keep it in the template for visibility.  Feel
  // free to delete this parameter in your app if you're not using it!
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  loadContext: AppLoadContext,
) {
  return isbot(request.headers.get('user-agent') || '')
    ? handleBotRequest(
      request,
      responseStatusCode,
      responseHeaders,
      remixContext,
    )
    : handleBrowserRequest(
      request,
      responseStatusCode,
      responseHeaders,
      remixContext,
    );
}

async function handleBotRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
) {
  const url = new URL(request.url);
  const { pathname } = url;
  const lang = pathname.split('/')[1];
  const instance = createInstance();
  let lng = returnLanguageIfSupported(lang) ?? (await i18next.getLocale(request));
  const ns = i18next.getRouteNamespaces(remixContext);

  await instance.use(initReactI18next).init({
    ...i18n,
    lng,
    ns,
  });

  return new Promise((resolve, reject) => {
    let shellRendered = false;
    const { pipe, abort } = renderToPipeableStream(
      <I18nextProvider i18n={instance}>
        <RemixServer
          context={remixContext}
          url={request.url}
          abortDelay={ABORT_DELAY}
        />
      </I18nextProvider>,
      {
        onAllReady() {
          shellRendered = true;
          const body = new PassThrough();
          const stream = createReadableStreamFromReadable(body);

          responseHeaders.set('Content-Type', 'text/html');

          resolve(
            new Response(stream, {
              headers: responseHeaders,
              status: responseStatusCode,
            }),
          );

          pipe(body);
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          responseStatusCode = 500;
          // Log streaming rendering errors from inside the shell.  Don't log
          // errors encountered during initial shell rendering since they'll
          // reject and get logged in handleDocumentRequest.
          if (shellRendered) {
            console.error(error);
          }
        },
      },
    );

    setTimeout(abort, ABORT_DELAY);
  });
}

async function handleBrowserRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
) {
  const url = new URL(request.url);
  const { pathname } = url;
  const lang = pathname.split('/')[1];
  const instance = createInstance();
  let lng = returnLanguageIfSupported(lang) ?? (await i18next.getLocale(request));
  const ns = i18next.getRouteNamespaces(remixContext);

  await instance.use(initReactI18next).init({
    ...i18n,
    lng,
    ns,
  });

  return new Promise((resolve, reject) => {
    let shellRendered = false;
    const { pipe, abort } = renderToPipeableStream(
      <I18nextProvider i18n={instance}>
        <RemixServer
          context={remixContext}
          url={request.url}
          abortDelay={ABORT_DELAY}
        />
      </I18nextProvider>,
      {
        onShellReady() {
          shellRendered = true;
          const body = new PassThrough();
          const stream = createReadableStreamFromReadable(body);

          responseHeaders.set('Content-Type', 'text/html');

          resolve(
            new Response(stream, {
              headers: responseHeaders,
              status: responseStatusCode,
            }),
          );

          pipe(body);
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          responseStatusCode = 500;
          // Log streaming rendering errors from inside the shell.  Don't log
          // errors encountered during initial shell rendering since they'll
          // reject and get logged in handleDocumentRequest.
          if (shellRendered) {
            console.error(error);
          }
        },
      },
    );

    setTimeout(abort, ABORT_DELAY);
  });
}

guelosuperstart avatar Jun 10 '24 05:06 guelosuperstart