Skip to content

Commit

Permalink
fix: squash fiberfailures, introduce "bad request error" (#791)
Browse files Browse the repository at this point in the history
  • Loading branch information
juliusmarminge authored May 8, 2024
1 parent 846d687 commit 69165fc
Show file tree
Hide file tree
Showing 9 changed files with 146 additions and 31 deletions.
6 changes: 6 additions & 0 deletions .changeset/flat-zoos-compete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"uploadthing": patch
"@uploadthing/shared": patch
---

fix: catch FiberFailure's and squash them to the original error
24 changes: 15 additions & 9 deletions packages/shared/src/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as Effect from "effect/Effect";
import { pipe } from "effect/Function";
import * as Schedule from "effect/Schedule";

import { FetchError, getRequestUrl } from "./tagged-errors";
import { BadRequestError, FetchError, getRequestUrl } from "./tagged-errors";
import type { FetchEsque, Json, ResponseEsque } from "./types";
import { filterObjectValues } from "./utils";

Expand Down Expand Up @@ -56,7 +56,7 @@ export const fetchEffUnknown = (
input: RequestInfo | URL,
/** Schema to be used if the response returned a 2xx */
init?: RequestInit,
): Effect.Effect<unknown, FetchError, FetchContext> => {
): Effect.Effect<unknown, FetchError | BadRequestError, FetchContext> => {
const requestUrl =
typeof input === "string"
? input
Expand All @@ -77,10 +77,11 @@ export const fetchEffUnknown = (
Effect.filterOrFail(
({ ok }) => ok,
({ json, status }) =>
new FetchError({
error: `Request to ${requestUrl} failed with status ${status}`,
data: json as Json,
new BadRequestError({
input,
status,
message: `Request to ${requestUrl} failed with status ${status}`,
error: json as Json,
}),
),
Effect.withSpan("fetchRawJson", {
Expand All @@ -94,7 +95,11 @@ export const fetchEffJson = <Schema>(
/** Schema to be used if the response returned a 2xx */
schema: S.Schema<Schema, any>,
init?: RequestInit,
): Effect.Effect<Schema, FetchError | ParseError, FetchContext> => {
): Effect.Effect<
Schema,
BadRequestError | FetchError | ParseError,
FetchContext
> => {
return fetchEff(input, init).pipe(
Effect.andThen((res) =>
Effect.tryPromise({
Expand All @@ -108,10 +113,11 @@ export const fetchEffJson = <Schema>(
Effect.filterOrFail(
({ ok }) => ok,
({ json, status }) =>
new FetchError({
error: `Request to ${getRequestUrl(input)} failed with status ${status}`,
data: json as Json,
new BadRequestError({
input,
status,
message: `Request to ${getRequestUrl(input)} failed with status ${status}`,
error: json,
}),
),
Effect.map(({ json }) => json),
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ export class UploadThingError<
}
}

export function getErrorTypeFromStatusCode(statusCode: number): ErrorCode {
for (const [code, status] of Object.entries(ERROR_CODES)) {
if (status === statusCode) {
return code as ErrorCode;
}
}
return "INTERNAL_SERVER_ERROR";
}

export function getStatusCodeFromError(error: UploadThingError<any>) {
return ERROR_CODES[error.code] ?? 500;
}
Expand Down
21 changes: 19 additions & 2 deletions packages/shared/src/tagged-errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { TaggedError } from "effect/Data";

import { isObject } from "./utils";

export class InvalidRouteConfigError extends TaggedError("InvalidRouteConfig")<{
reason: string;
}> {
Expand Down Expand Up @@ -58,8 +60,23 @@ export const getRequestUrl = (input: RequestInfo | URL) => {
return input.toString();
};

export class FetchError<T = unknown> extends TaggedError("FetchError")<{
export class FetchError extends TaggedError("FetchError")<{
readonly input: RequestInfo | URL;
readonly error: unknown;
readonly data?: T;
}> {}

export class BadRequestError<T = unknown> extends TaggedError(
"BadRequestError",
)<{
readonly message: string;
readonly input: RequestInfo | URL;
readonly status: number;
readonly error: T;
}> {
getMessage() {
if (isObject(this.error)) {
if (typeof this.error.message === "string") return this.error.message;
}
return this.message;
}
}
48 changes: 33 additions & 15 deletions packages/uploadthing/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import type { ParseError } from "@effect/schema/ParseResult";
import * as Arr from "effect/Array";
import * as Cause from "effect/Cause";
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import { unsafeCoerce } from "effect/Function";
import * as Option from "effect/Option";
import * as Runtime from "effect/Runtime";

import type { FetchError } from "@uploadthing/shared";
import {
exponentialBackoff,
FetchContext,
fetchEffUnknown,
getErrorTypeFromStatusCode,
isObject,
resolveMaybeUrlArg,
RetryError,
Expand Down Expand Up @@ -114,18 +117,23 @@ export const genUploader = <TRouter extends FileRouter>(
package: initOpts.package,
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
input: (opts as any).input as inferEndpointInput<TRouter[TEndpoint]>,
}).pipe(
Effect.provideService(FetchContext, {
fetch: globalThis.fetch.bind(globalThis),
baseHeaders: {
"x-uploadthing-version": UPLOADTHING_VERSION,
"x-uploadthing-api-key": undefined,
"x-uploadthing-fe-package": initOpts.package,
"x-uploadthing-be-adapter": undefined,
},
}),
Effect.runPromise,
);
})
.pipe(
Effect.provideService(FetchContext, {
fetch: globalThis.fetch.bind(globalThis),
baseHeaders: {
"x-uploadthing-version": UPLOADTHING_VERSION,
"x-uploadthing-api-key": undefined,
"x-uploadthing-fe-package": initOpts.package,
"x-uploadthing-be-adapter": undefined,
},
}),
Effect.runPromise,
)
.catch((error) => {
if (!Runtime.isFiberFailure(error)) throw error;
throw Cause.squash(error[Runtime.FiberFailureCauseId]);
});
};

type Done = { status: "done"; callbackData: unknown };
Expand All @@ -138,8 +146,9 @@ const isPollingResponse = (input: unknown): input is PollingResponse => {
return input.status === "still waiting";
};

const isPollDone = (input: PollingResponse): input is Done =>
input.status === "done";
const isPollingDone = (input: PollingResponse): input is Done => {
return input.status === "done";
};

const uploadFile = <
TRouter extends FileRouter,
Expand Down Expand Up @@ -177,11 +186,20 @@ const uploadFile = <
fetchEffUnknown(presigned.pollingUrl, {
headers: { authorization: presigned.pollingJwt },
}).pipe(
Effect.catchTag("BadRequestError", (e) =>
Effect.fail(
new UploadThingError({
code: getErrorTypeFromStatusCode(e.status),
message: e.message,
cause: e.error,
}),
),
),
Effect.filterOrDieMessage(
isPollingResponse,
"received a non PollingResponse from the polling endpoint",
),
Effect.filterOrFail(isPollDone, () => new RetryError()),
Effect.filterOrFail(isPollingDone, () => new RetryError()),
Effect.map(({ callbackData }) => callbackData),
Effect.retry({
while: (res) => res instanceof RetryError,
Expand Down
11 changes: 10 additions & 1 deletion packages/uploadthing/src/internal/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,21 @@ export const buildRequestHandler =
parseAndValidateRequest(input, opts, adapter),
),
Effect.catchTags({
BadRequestError: (e) =>
Effect.fail(
new UploadThingError({
code: "INTERNAL_SERVER_ERROR",
message: e.getMessage(),
cause: e,
data: e.error as never,
}),
),
FetchError: (e) =>
new UploadThingError({
code: "INTERNAL_SERVER_ERROR",
message: typeof e.error === "string" ? e.error : e.message,
cause: e,
data: e.data as never,
data: e.error as never,
}),
ParseError: (e) =>
new UploadThingError({
Expand Down
15 changes: 14 additions & 1 deletion packages/uploadthing/src/internal/ut-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import type * as S from "@effect/schema/Schema";
import * as Effect from "effect/Effect";

import type { FetchContext, MaybePromise } from "@uploadthing/shared";
import { fetchEffJson, UploadThingError } from "@uploadthing/shared";
import {
fetchEffJson,
getErrorTypeFromStatusCode,
UploadThingError,
} from "@uploadthing/shared";

import { UPLOADTHING_VERSION } from "./constants";
import { maybeParseResponseXML } from "./s3-error-parser";
Expand Down Expand Up @@ -68,6 +72,15 @@ export const createUTReporter =
...headers,
},
}).pipe(
Effect.catchTag("BadRequestError", (e) =>
Effect.fail(
new UploadThingError({
code: getErrorTypeFromStatusCode(e.status),
message: e.getMessage(),
cause: e.error,
}),
),
),
Effect.catchTag("FetchError", (e) =>
Effect.fail(
new UploadThingError({
Expand Down
40 changes: 38 additions & 2 deletions packages/uploadthing/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ describe("uploadFiles", () => {
skipPolling: true,
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[(FiberFailure) UploadThingError: Failed to upload file foo.txt to S3]`,
`[UploadThingError: Failed to upload file foo.txt to S3]`,
);

expect(requestsToDomain("amazonaws.com")).toHaveLength(1);
Expand Down Expand Up @@ -322,7 +322,7 @@ describe("uploadFiles", () => {
skipPolling: true,
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[(FiberFailure) UploadThingError: Failed to upload file foo.txt to S3]`,
`[UploadThingError: Failed to upload file foo.txt to S3]`,
);

expect(requestsToDomain("amazonaws.com")).toHaveLength(7);
Expand All @@ -344,4 +344,40 @@ describe("uploadFiles", () => {

close();
});

it("handles too big file size errors", async ({ db, task }) => {
const { uploadFiles, close } = setupUTServer(task);

const tooBigFile = new File(
[new ArrayBuffer(20 * 1024 * 1024)],
"foo.txt",
{
type: "text/plain",
},
);

await expect(
uploadFiles("foo", {
files: [tooBigFile],
skipPolling: true,
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[UploadThingError: Invalid config: FileSizeMismatch]`,
);
});

it("handles invalid file type errors", async ({ db, task }) => {
const { uploadFiles, close } = setupUTServer(task);

const file = new File(["foo"], "foo.png", { type: "image/png" });

await expect(
uploadFiles("foo", {
files: [file],
skipPolling: true,
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[UploadThingError: Invalid config: InvalidFileType]`,
);
});
});
3 changes: 2 additions & 1 deletion packages/uploadthing/test/request-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,8 @@ describe("bad request handling", () => {
message:
"Request to https://uploadthing.com/api/prepareUpload failed with status 404",
data: { error: "Not found" },
cause: "FetchError",
cause:
"BadRequestError: Request to https://uploadthing.com/api/prepareUpload failed with status 404",
});
});
});

0 comments on commit 69165fc

Please sign in to comment.