Skip to content

Commit 072fcc3

Browse files
fix: gracefully handle download errors (#1058)
1 parent a9b6559 commit 072fcc3

File tree

4 files changed

+250
-210
lines changed

4 files changed

+250
-210
lines changed

.changeset/modern-numbers-cough.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"uploadthing": patch
3+
---
4+
5+
fix: gracefully handle download errors in `utapi.uploadFilesFromUrl`

packages/uploadthing/src/sdk/index.ts

Lines changed: 77 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
1-
import type { FetchHttpClient } from "@effect/platform";
1+
import type { FetchHttpClient, HttpClientError } from "@effect/platform";
22
import {
33
HttpClient,
44
HttpClientRequest,
55
HttpClientResponse,
66
} from "@effect/platform";
77
import * as Arr from "effect/Array";
8+
import * as Cause from "effect/Cause";
89
import * as Effect from "effect/Effect";
9-
import type * as ManagedRuntime from "effect/ManagedRuntime";
10-
import * as Predicate from "effect/Predicate";
10+
import type { ManagedRuntime } from "effect/ManagedRuntime";
11+
import type { ParseError } from "effect/ParseResult";
1112
import * as Redacted from "effect/Redacted";
1213
import * as S from "effect/Schema";
1314

14-
import type {
15-
ACL,
16-
FetchEsque,
17-
MaybeUrl,
18-
SerializedUploadThingError,
19-
} from "@uploadthing/shared";
15+
import type { ACL, FetchEsque, MaybeUrl } from "@uploadthing/shared";
2016
import { parseTimeToSeconds, UploadThingError } from "@uploadthing/shared";
2117

2218
import { ApiUrl, UPLOADTHING_VERSION, UTToken } from "../internal/config";
@@ -36,14 +32,14 @@ import type {
3632
UTApiOptions,
3733
} from "./types";
3834
import { UTFile } from "./ut-file";
39-
import { downloadFiles, guardServerOnly, uploadFilesInternal } from "./utils";
35+
import { downloadFile, guardServerOnly, uploadFile } from "./utils";
4036

4137
export { UTFile };
4238

4339
export class UTApi {
4440
private fetch: FetchEsque;
4541
private defaultKeyType: "fileKey" | "customId";
46-
private runtime: ManagedRuntime.ManagedRuntime<
42+
private runtime: ManagedRuntime<
4743
HttpClient.HttpClient | FetchHttpClient.Fetch,
4844
UploadThingError
4945
>;
@@ -83,18 +79,38 @@ export class UTApi {
8379
Effect.flatMap(HttpClientResponse.schemaBodyJson(responseSchema)),
8480
Effect.scoped,
8581
);
86-
}).pipe(Effect.withLogSpan("utapi.#requestUploadThing"));
82+
}).pipe(
83+
Effect.catchTag(
84+
"ConfigError",
85+
(e) =>
86+
new UploadThingError({
87+
code: "INVALID_SERVER_CONFIG",
88+
message:
89+
"There was an error with the server configuration. More info can be found on this error's `cause` property",
90+
cause: e,
91+
}),
92+
),
93+
Effect.withLogSpan("utapi.#requestUploadThing"),
94+
);
8795

88-
private executeAsync = async <A, E>(
89-
program: Effect.Effect<A, E, HttpClient.HttpClient>,
96+
private executeAsync = async <A>(
97+
program: Effect.Effect<
98+
A,
99+
UploadThingError | ParseError | HttpClientError.HttpClientError,
100+
HttpClient.HttpClient
101+
>,
90102
signal?: AbortSignal,
91103
) => {
92-
const result = await program.pipe(
104+
const exit = await program.pipe(
93105
Effect.withLogSpan("utapi.#executeAsync"),
94-
(e) => this.runtime.runPromise(e, signal ? { signal } : undefined),
106+
(e) => this.runtime.runPromiseExit(e, signal ? { signal } : undefined),
95107
);
96108

97-
return result;
109+
if (exit._tag === "Failure") {
110+
throw Cause.squash(exit.cause);
111+
}
112+
113+
return exit.value;
98114
};
99115

100116
/**
@@ -117,31 +133,34 @@ export class UTApi {
117133
files: FileEsque[],
118134
opts?: UploadFilesOptions,
119135
): Promise<UploadFileResult[]>;
120-
async uploadFiles(
136+
uploadFiles(
121137
files: FileEsque | FileEsque[],
122138
opts?: UploadFilesOptions,
123139
): Promise<UploadFileResult | UploadFileResult[]> {
124140
guardServerOnly();
125141

126-
const uploads = await this.executeAsync(
127-
Effect.flatMap(
128-
uploadFilesInternal({
129-
files: Arr.ensure(files),
130-
contentDisposition: opts?.contentDisposition ?? "inline",
131-
acl: opts?.acl,
142+
const program: Effect.Effect<
143+
UploadFileResult | UploadFileResult[],
144+
never,
145+
HttpClient.HttpClient
146+
> = Effect.forEach(Arr.ensure(files), (file) =>
147+
uploadFile(file, opts ?? {}).pipe(
148+
Effect.match({
149+
onSuccess: (data) => ({ data, error: null }),
150+
onFailure: (error) => ({ data: null, error }),
132151
}),
133-
(ups) => Effect.succeed(Array.isArray(files) ? ups : ups[0]),
134-
).pipe(
135-
Effect.tap((res) =>
136-
Effect.logDebug("Finished uploading").pipe(
137-
Effect.annotateLogs("uploadResult", res),
138-
),
152+
),
153+
).pipe(
154+
Effect.map((ups) => (Array.isArray(files) ? ups : ups[0])),
155+
Effect.tap((res) =>
156+
Effect.logDebug("Finished uploading").pipe(
157+
Effect.annotateLogs("uploadResult", res),
139158
),
140-
Effect.withLogSpan("uploadFiles"),
141159
),
142-
opts?.signal,
160+
Effect.withLogSpan("uploadFiles"),
143161
);
144-
return uploads;
162+
163+
return this.executeAsync(program, opts?.signal);
145164
}
146165

147166
/**
@@ -165,49 +184,37 @@ export class UTApi {
165184
urls: (MaybeUrl | UrlWithOverrides)[],
166185
opts?: UploadFilesOptions,
167186
): Promise<UploadFileResult[]>;
168-
async uploadFilesFromUrl(
187+
uploadFilesFromUrl(
169188
urls: MaybeUrl | UrlWithOverrides | (MaybeUrl | UrlWithOverrides)[],
170189
opts?: UploadFilesOptions,
171190
): Promise<UploadFileResult | UploadFileResult[]> {
172191
guardServerOnly();
173192

174-
const downloadErrors: Record<number, SerializedUploadThingError> = {};
175-
const arr = Arr.ensure(urls);
176-
177-
const program = Effect.gen(function* () {
178-
const downloadedFiles = yield* downloadFiles(arr, downloadErrors).pipe(
179-
Effect.map((files) => Arr.filter(files, Predicate.isNotNullable)),
180-
);
181-
182-
yield* Effect.logDebug(
183-
`Downloaded ${downloadedFiles.length}/${arr.length} files`,
184-
).pipe(Effect.annotateLogs("downloadedFiles", downloadedFiles));
185-
186-
const uploads = yield* uploadFilesInternal({
187-
files: downloadedFiles,
188-
contentDisposition: opts?.contentDisposition ?? "inline",
189-
acl: opts?.acl,
190-
});
191-
192-
/** Put it all back together, preserve the order of files */
193-
const responses = arr.map((_, index) => {
194-
if (downloadErrors[index]) {
195-
return { data: null, error: downloadErrors[index] };
196-
}
197-
return uploads.shift()!;
198-
});
199-
200-
/** Return single object or array based on input urls */
201-
const uploadFileResponse = Array.isArray(urls) ? responses : responses[0];
202-
yield* Effect.logDebug("Finished uploading").pipe(
203-
Effect.annotateLogs("uploadResult", uploadFileResponse),
204-
Effect.withLogSpan("utapi.uploadFilesFromUrl"),
205-
);
206-
207-
return uploadFileResponse;
208-
}).pipe(Effect.withLogSpan("uploadFilesFromUrl"));
193+
const program: Effect.Effect<
194+
UploadFileResult | UploadFileResult[],
195+
never,
196+
HttpClient.HttpClient
197+
> = Effect.forEach(Arr.ensure(urls), (url) =>
198+
downloadFile(url).pipe(
199+
Effect.flatMap((file) => uploadFile(file, opts ?? {})),
200+
Effect.match({
201+
onSuccess: (data) => ({ data, error: null }),
202+
onFailure: (error) => ({ data: null, error }),
203+
}),
204+
),
205+
)
206+
.pipe(
207+
Effect.map((ups) => (Array.isArray(urls) ? ups : ups[0])),
208+
Effect.tap((res) =>
209+
Effect.logDebug("Finished uploading").pipe(
210+
Effect.annotateLogs("uploadResult", res),
211+
),
212+
),
213+
Effect.withLogSpan("uploadFiles"),
214+
)
215+
.pipe(Effect.withLogSpan("uploadFilesFromUrl"));
209216

210-
return await this.executeAsync(program, opts?.signal);
217+
return this.executeAsync(program, opts?.signal);
211218
}
212219

213220
/**

0 commit comments

Comments
 (0)