Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose error message from shadow endpoint errors in __error.svelte #4032

Conversation

georgecrawford
Copy link
Contributor

@georgecrawford georgecrawford commented Feb 21, 2022

Fixes #3715

When a shadow endpoint throws an error (or returns a status code > 400, with an error property in the body), it is useful for the __error.svelte handler to have access to that error message. This could be obfuscated in non-dev environments, if that's what the maintainers would advise.

Ideally, we'd find a way to transfer the full error object. This is super useful in local development - in the past I've used https://www.npmjs.com/package/serialize-error to serialize/deserialize the whole error object over JSON so that the __error.svelte handler can print the stack trace.

NB: I haven't written a new test yet, but will do so if this approach is acceptable.

Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests

  • Run the tests with pnpm test and lint the project with pnpm lint and pnpm check

Changesets

  • If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running pnpx changeset and following the prompts. All changesets should be patch until SvelteKit 1.0

@changeset-bot
Copy link

changeset-bot bot commented Feb 21, 2022

⚠️ No Changeset found

Latest commit: 9580dae

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@Rich-Harris
Copy link
Member

Returning an error property inside body feels like a bit of a kludge to me. I wonder if we should instead have a top level error:

export function get({ params }) {
  const thing = await get_thing(params.thing);

  if (!thing) {
    return {
      status: 404,
      error: new Error('not a thing')
    };
  }

  return {
     body: { thing }
  };
}

This would solve a useful additional purpose: at the moment it's awkward to differentiate between errors in a non-GET handler that should result in an error page (let's call them 'fatal errors'), and those that should be treated as props ('validation errors'). We could fix that:

// post handler can return validation errors
export function post({ request, locals }) {
  if (!locals.user) {
    // fatal error — results in error page
    return {
      status: 401,
      error: new Error('show yourself, coward')
    };
  }

  if (!locals.user.mojo < 50) {
    // fatal error — results in error page
    return {
      status: 403,
      error: new Error('insufficient mojo')
    };
  }

  const [errors, item] = await create_item(await request.formData());

  if (errors) {
    // validation errors — mixed with GET props and rendered
    return {
      status: 400,
      body: { errors }
    };
  }

  // great success
  return {
    body: { item }
  };
}

Not sure if this should apply to standalone endpoints, or what it would mean if it did. Also, I'm glossing over a bunch of implementation details here.

@georgecrawford
Copy link
Contributor Author

In my brief look into the code, I initially assumed that a top-level error returned from an endpoint would work just like the top-level error in a load() function. I like this alignment of APIs, and feel it's the natural thing to do. I'm afraid I don't have an informed opinion on the standalone endpoint question, but I could try and work out a basic implementation if you'd like?

@Rich-Harris
Copy link
Member

Been discussing this with the other maintainers. We concluded that rather than adding a new top-level error property, or having an error prop within body, it would make sense to allow body to be an error:

// post handler can return validation errors
export function post({ request, locals }) {
  if (!locals.user) {
    // fatal error — results in error page
    return {
      status: 401,
-     error: new Error('show yourself, coward')
+     body: new Error('show yourself, coward')
    };
  }

  if (!locals.user.mojo < 50) {
    // fatal error — results in error page
    return {
      status: 403,
-     error: new Error('insufficient mojo')
+     body: new Error('insufficient mojo')
    };
  }

  const [errors, item] = await create_item(await request.formData());

  if (errors) {
    // validation errors — mixed with GET props and rendered
    return {
      status: 400,
      body: { errors }
    };
  }

  // great success
  return {
    body: { item }
  };
}

So this code...

// Redirects are respected...
if (status >= 300 && status < 400) {
data.redirect = /** @type {string} */ (
headers instanceof Headers ? headers.get('location') : headers.location
);
return data;
}
// ...but 4xx and 5xx status codes _don't_ result in the error page
// rendering for non-GET requests — instead, we allow the page
// to render with any validation errors etc that were returned
data.body = body;
...might be amended thusly:

			// Redirects are respected...
			if (status >= 300 && status < 400) {
				data.redirect = /** @type {string} */ (
					headers instanceof Headers ? headers.get('location') : headers.location
				);
				return data;
			}

+			// ...errors are propagated to the page...
+			if (status >= 400 && body instanceof Error) {
+				data.error = body;
+				return data;
+			}

			// ...but 4xx and 5xx status codes _don't_ result in the error page
+			// if they are accompanied by a non-Error body
			// rendering for non-GET requests — instead, we allow the page
			// to render with any validation errors etc that were returned
			data.body = body;

When hitting the endpoint directly, errors could be serialized as POJOs:

export function get() {
  return {
    status: 400,
    body: new Error('nope', { cause: {...} })
  };
}

// equivalent to this in dev
export function get() {
  return {
    status: 400,
    body: {
      message: 'nope',
      stack: new Error().stack,
      cause: {...}
    }
  };
}

// or this in prod
export function get() {
  return {
    status: 400,
    body: {
      message: 'nope',
      cause: {...}
    }
  };
}

(If cause is an error, it could be serialized with the same logic.)

The nice thing about this is it would apply to standalone endpoints just as easily, and error responses would have a standard 'shape'.

Thoughts?

@georgecrawford
Copy link
Contributor Author

Yep, I like this a lot. Questions:

  1. Am I right that when the endpoint is hit during SSR the full, un-serialized Error instance would be made available to the __error.svelte's load() function?
  2. You say When hitting the endpoint directly, errors could be serialized as POJOs - would this be done automatically by the framework, or would a user be responsible? I hope and assume the former is what you meant?
  3. When an endpoint Error is returned in a client-side fetch/render, would you propose to un-serialize the POJO so that the __error.svelte's load() function again receives an actual Error instance?
  4. Would you consider a library such as https://github.com/sindresorhus/serialize-error to do this serialize/un-serialize work properly for us? And what should the approach be to preventing stack traces being sent in non-dev environments? In my project so far, I run code like this in hooks.js:
    // Only leak stack-trace in local dev
    if (NODE_ENV === 'development') {
        const serialized = serializeError(error);
        return new Response(JSON.stringify({error: serialized}), {
            status: 500,
            headers,
        });
    }

    // Obfuscate API error messages in non-dev, so that they don't leak
    // sensitive information to the client
    return new Response(
        JSON.stringify({
            error: {
                message: 'Internal Server Error',
                sentryId,
                requestId,
            },
        }),
        {
            status: 500,
            headers,
        }
    );
  1. You can see that I make use of attaching various metadata to error objects (e.g. error.sentryId) and I'd like those to be sent over the wire in the POJO and to be available to me in __error.svelte. Would you support this use-case?

@georgecrawford georgecrawford marked this pull request as draft February 22, 2022 13:12
@Rich-Harris
Copy link
Member

man, the devil is in the details!

  1. that's what happens if an error is thrown (or returned as the error property) from load, so the natural answer would be 'yes'. that said, it would introduce a server/client inconsistency when the error is instead coming from an endpoint (related: 3). so I think we should probably use the POJO for both SSR and hydration in this case
  2. by the framework, yep
  3. i think it's probably fine for the error object to be a POJO in the client as long as it's documented. deserializing it feels a bit uncanny valley — sure, the deserialized error object might have a MyCustomError name, but it wouldn't have the MyCustomError prototype
  4. for serializing, possibly (per 3, I don't think we need to deserialize). we might be better off rolling our own thing because a) it doesn't support cause, and we need to handle stack anyway. so it might be easier to go with a simpler approach for now.
  5. yeah, that seems reasonable

Just to fill you in on some separate-but-related maintainers' chat, we've been wondering about a way to prevent unexpected (i.e. thrown) errors from reaching the client, since they might expose privileged information. That might look something like allowing handleError to return an error object, with a fallback:

error = options.handle_error(error, event) || new Error('Internal server error');
export function handleError({ error, event }) {
  console.error(`Error: ${event.request.method} ${event.request.url}`);
  console.error(error.stack);

  if (error.message === 'Please obfuscate me') {
    return new Error('A terrible thing happened.');
  }

  // otherwise make the message visible to the client
  return error;
}

I don't think it would affect this PR, but worth flagging anyway.

@georgecrawford
Copy link
Contributor Author

Thanks Rich. That all sounds good - especially the new powers designated to handleError, which make a lot of sense.

I'm not sure how much time I'll be able to give this, but I'll do my best. Is it something you'd want to work on yourself, or do you have higher priorities atm?

@Rich-Harris
Copy link
Member

It's not a burning priority, so I probably won't pick it up in the next few days. But no worries if you don't have the bandwidth — it'll happen eventually either way

@georgecrawford
Copy link
Contributor Author

@Rich-Harris What are your thoughts on this start I've made? A few notes:

  1. In load_node.js, the code branches for is_get and !is_get were so similar that I've combined them. I will ensure I haven't regressed anything when I come to getting the tests passing! I'd be interested to hear your thoughts on how a GET/HEAD endpoint request should be handled differently to another method, if there are any examples.
  2. Two new utility methods: stringify_safe (which it would make sense to run on all endpoint POJOs if we're stringifying them, just in case there are circular references) and enumerate_error_props (which ensures we catch the non-enumerable name, message and stack properties for a potentially deeply-nested tree of Error objects, along with any other enumerable properties the user has added). Interested in your thoughts, and the extent to which you're happy to use code from https://github.com/moll/json-stringify-safe as I've done.
  3. I haven't tackled typings yet, and may well need help there
  4. I haven't written tests, but I have prepared a test case which I'm running with pnpm build && pnpm run dev. My findings are that curl http://localhost:3000/shadowed/error-get and curl http://localhost:3000/shadowed/error-get/__data.json both seem to give what I'd expect, and that a client-side navigation from http://localhost:3000/shadowed to http://localhost:3000/shadowed/error-get correctly shows the error. But, when a SSR response from http://localhost:3000/shadowed/error-get is hydrated the error is lost and I see This is your custom error page saying: "undefined". Which file should I be looking at to fix this?

Thanks for any tips!

@georgecrawford
Copy link
Contributor Author

Hah, and now I find render.js, and find that it's using devalue by some guy named Rich....https://github.com/Rich-Harris/devalue

@georgecrawford
Copy link
Contributor Author

OK, I managed to get the hydration working by continuing to use devalue, but to run a new enumerate_error_props routine on any errors first.

I can now curl http://localhost:3000/shadowed/error-get and curl http://localhost:3000/shadowed/error-get/__data.json and get what I expect to see, and also do both a client-side navigation and a SSR to http://localhost:3000/shadowed/error-get and see the correct error object rendered.

I experimented with using devalue on the server to prevent cyclical references in JSON output, but it's not the right tool - devalue aims to preserve those cycles by constructing a JS representation which will inflate into the cyclical object. What we need is something which can serialize to JSON, so we need to break the cycles. Hence my introduction of the stringify_safe util.

@Rich-Harris very interested to hear your thoughts on all this.

@georgecrawford
Copy link
Contributor Author

I'm a bit stuck on what to do next with this PR. Happy to move it forward with tests, etc., but I'd like some confirmation that it's the right approach.

@cdcarson
Copy link
Contributor

cdcarson commented Apr 3, 2022

Appreciate your work on this. Inconsistent SvelteKit error handling is a pretty big blocker for me at this point, especially if (as discussed above) there are forthcoming changes to how endpoints are expected to throw or return errors.

I've taken a stab at fixing the problem as it now stands. I'm pasting my code here because this PR seems to be where you are tracking the issue, and I'm not quite sure whether my (somewhat opinionated) solution aligns with what the maintainers are planning. I'm working off this commit. I've attached my full, changed files to this comment.

Let me know if the following makes sense. Happy to flesh this out with tests, etc.

The Issue

Client side code makes a fetch call to an endpoint. There are two cases:

  1. The SvelteKit client fetches shadowed page's __data.json. This happens when javascript is enabled and the user navigates.
  2. Userland client-side code fetches any endpoint.

In both cases, if the endpoint throws...

  • Currently: the SvelteKit server responds with text/html -- the rendered __error.svelte page.
  • Expected: the SvelteKit server responds with a JSON representation of the error.

The first case (SvelteKit client navigation) is the most noticeable failing case (see #3715 (comment) and #4303 (comment).)

But text/html is returned in all cases when an endpoint throws. If userland client code makes a fetch request, one would expect an error to be returned as JSON. The only current workaround is to explicitly return something from the endpoint rather than to simply throw an error. This is cumbersome.

Opinions/Notes

Being able to throw from anywhere is hugely important. Endpoint code should not have to explicitly return an Error in the body for every possible error condition. SvelteKit should just take care of it -- catch the error (or whatever was thrown) and return a 500 response with some representation of the error, either text/html or JSON. This allows for repetitive domain logic (say, authorization or existence) to be abstracted out from endpoints.

The baseline error handling paradigm (in userland) should be to throw an Error with some user-facing message. This probably takes care of what most folks need. It's simple and easily understood. But it doesn't rule out more complicated error shapes. If you throw a pojo, SvelteKit will serialize it to a JSON string and return an Error with that serialized string as the message (see coalesce_to_error.) You can then parse that out in __error.svelte or elsewhere. But all that can be left to the developer. SvelteKit should keep things as simple as possible.

The one exception to this paradigm (that I can currently think of) is dealing with form validation errors in shadow endpoints. In this case it make sense to return something rather than throw. The logic of validating a specific form is likely not going to be repeated across multiple endpoints.

Error serialization. Developers can already see the server error stack in the console. So I've ignored that and other gnarly serialization concerns. In my change I just do body: JSON.stringify({message: error.message}). In my opinion, this is the way it should be, but essentially how or how much the errors are serialized a side issue.

The Fix

Only two minor changes were necessary. src.zip

First, fix the server to check whether the request is a client side fetch, and if so, respond with JSON...

// kit/packages/kit/src/runtime/server/index.js

/** @type {import('types').Respond} */
export async function respond(request, options, state) {
  // omitted for clarity
  try {
    // omitted for clarity
  } catch (/** @type {unknown} */ e) {
    const error = coalesce_to_error(e);

    options.handle_error(error, event);

    // start change
    /**
     *  Note: error is guaranteed to be an Error at this point, because coalesce_to_error
     *
     * 	We need to check whether the request is a client side fetch. There are two cases:
     * - It's a navigation, in which case the const is_data_request,
     *   defined further up in this function, is true
     * - It's some other client side fetch initiated from userland (as opposed to SvelteKit) code.
     *   In this case we check whether the Accept header starts with application/json.
     */
    if (
      is_data_request ||
      (typeof event.request.headers.get('accept') === 'string' &&
        event.request.headers.get('accept')?.startsWith('application/json'))
    ) {
      /**
       * Return the error as JSON. For simplicity's sake, boil it down to the
       * message.
       */
      return new Response(JSON.stringify({ message: error.message }), {
        status: 500,
        headers: { 'content-type': 'application/json; charset=utf-8' }
      });
    }
    // end change

    try {
      const $session = await options.hooks.getSession(event);
      return await respond_with_error({
        event,
        options,
        state,
        $session,
        status: 500,
        error,
        resolve_opts
      });
    } catch (/** @type {unknown} */ e) {
      const error = coalesce_to_error(e);

      return new Response(options.dev ? error.stack : error.message, {
        status: 500
      });
    }
  }
}

Second, fix the client to handle errors resulting from navigation to a shadowed route. This is the same change to client.js made in the current PR. It properly hydrates __error.svelte with the response body.

// kit/packages/kit/src/runtime/client/client.js

export function create_client({ target, session, base, trailing_slash }) {
  // omitted for clarity...

  async function load_route({ id, url, params, route }, no_cache) {
    // omitted for clarity...

    load: for (let i = 0; i < a.length; i += 1) {
      /** @type {import('./types').BranchNode | undefined} */
      let node;

      try {
        // omitted for clarity...

        if (is_shadow_page) {
          const res = await fetch(
            `${url.pathname}${
              url.pathname.endsWith('/') ? '' : '/'
            }__data.json${url.search}`,
            {
              headers: {
                'x-sveltekit-load': 'true'
              }
            }
          );
          if (res.ok) {
            // omitted for clarity...
          } else {
            status = res.status;
            // start change
            try {
              error = await res.json();
            } catch (e) {
              error = new Error('Failed to load data');
            }
            // end change
          }
        }
        // omitted for clarity...
      } catch (e) {
        status = 500;
        error = coalesce_to_error(e);
      }

      // omitted for clarity...
  }

  // omitted for clarity...
}

@coryvirok
Copy link
Contributor

To connect some dots here, I couldn't agree more with @cdcarson sentiment:

Being able to throw from anywhere is hugely important. Endpoint code should not have to explicitly return an Error in the body for every possible error condition. SvelteKit should just take care of it -- catch the error (or whatever was thrown) and return a 500 response with some representation of the error, either text/html or JSON. This allows for repetitive domain logic (say, authorization or existence) to be abstracted out from endpoints.

We are tracking something similar in

I brought up the "throw an http error from endpoints" before #4712

At a high level, I'm seeing that SvelteKit prefers, (assumes?) a return-everything pattern, similar to Golang. Exceptions and exception handling also need to be handled but it's not apparent that exceptions are a supported way to control flow, even when the flow is "return an error to the client because something bad happened but I don't know what."

@cdcarson cdcarson mentioned this pull request Jun 29, 2022
5 tasks
@Rich-Harris
Copy link
Member

Closing in favour of #5314 — thank you

@Rich-Harris Rich-Harris closed this Jul 7, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Shadow endpoint errors should allow me to specify the error message.
5 participants