Skip to content

Commit 843a555

Browse files
committed
Generalize API error handling
I've already run into bugs twice before due to messing up the boilerplate code that was previously necessary. This abstraction makes the interface for API error handling much more simple (and therefore robust). It's not perfect, but it solves the problem.
1 parent 3e26230 commit 843a555

8 files changed

Lines changed: 226 additions & 195 deletions

File tree

frontend/components/DefaultHeader.vue

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,16 @@ async function signOut() {
3131
3232
signOutLoading.value = true;
3333
34-
try {
35-
await api("/users/$me/sessions/$current", {
36-
method: "DELETE",
37-
}).finally(() => {
38-
signOutLoading.value = false;
39-
});
40-
} catch (error) {
41-
const errorCode = getApiErrorCode(error);
42-
if (!(errorCode === "AUTH_FAILED" || errorCode === "RESOURCE_NOT_FOUND")) {
43-
throw error;
44-
}
45-
}
34+
await api("/users/$me/sessions/$current", {
35+
method: "DELETE",
36+
37+
catchApiErrors: {
38+
AUTH_FAILED: () => Promise.resolve(),
39+
RESOURCE_NOT_FOUND: () => Promise.resolve(),
40+
},
41+
}).finally(() => {
42+
signOutLoading.value = false;
43+
});
4644
4745
setMe(null);
4846
}

frontend/composables/useApi.ts

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,27 @@
11
import type { UseFetchOptions } from "nuxt/app";
2-
import { createFetchError, type FetchError } from "ofetch";
2+
import type { ApiOnlyOptions } from "~/utils/api";
33

44
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- We don't have an accurate type for this, and using `unknown` would require many pointless type assertions or runtime checks.
55
type DefaultResT = any;
66

7-
export interface UseApiOptions<ResT> extends UseFetchOptions<ResT> {
8-
/**
9-
* Whether to prevent the default behavior (an error box) when a response
10-
* error occurs.
11-
*/
12-
shouldIgnoreResponseError?: (error: FetchError) => boolean;
13-
}
7+
export interface UseApiOptions<ResT, CaughtResT>
8+
extends Omit<UseFetchOptions<ResT>, "$fetch">,
9+
ApiOnlyOptions<CaughtResT> {}
1410

1511
/**
1612
* A custom [`useFetch`](https://nuxt.com/docs/api/composables/use-fetch)
1713
* wrapper for our API.
1814
*/
19-
export default function useApi<ResT = DefaultResT>(
15+
export default function useApi<ResT = DefaultResT, CaughtResT = never>(
2016
url: MaybeRefOrGetter<string>,
21-
{ shouldIgnoreResponseError, ...options }: UseApiOptions<ResT> = {},
17+
options: UseApiOptions<ResT, CaughtResT> = {},
2218
) {
23-
const errorBoxes = useErrorBoxes();
24-
2519
return useFetch<ResT>(url, {
2620
// The default is `cancel`, but canceling previous requests doesn't stop the
2721
// backend from processing them. This lets any previous request finish and
2822
// reuses it, which is quicker and more reliable since it already started.
2923
dedupe: "defer",
3024

31-
onRequestError(ctx) {
32-
errorBoxes.handleError(ctx.error);
33-
},
34-
35-
onResponseError(ctx) {
36-
const error = createFetchError(ctx);
37-
38-
if (shouldIgnoreResponseError?.(error)) {
39-
return;
40-
}
41-
42-
errorBoxes.handleError(error);
43-
},
44-
4525
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- The error from removing this assertion is too convoluted for me to decipher, and I see no reason this shouldn't work at runtime.
4626
...(options as any),
4727
$fetch: api,

frontend/composables/useMe.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ export default async function useMe(): Promise<Readonly<Ref<User | null>>> {
1313
// Only fetch the user if it wasn't already set by `setMe` elsewhere.
1414
if (me.value === "unknown") {
1515
await callOnce(async () => {
16-
const { data } = await useApi<User>("/users/$me", {
17-
shouldIgnoreResponseError: (error) =>
18-
getApiErrorCode(error) === "RESOURCE_NOT_FOUND",
16+
const { data } = await useApi<User, null>("/users/$me", {
17+
catchApiErrors: {
18+
RESOURCE_NOT_FOUND: () => Promise.resolve(null),
19+
},
1920
});
2021

2122
me.value = data.value;

frontend/pages/reset-password.vue

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,16 @@ const passwordResetResponse = await useApi("/password-reset", {
3737
},
3838
},
3939
40-
shouldIgnoreResponseError: (error) => {
41-
const code = getApiErrorCode(error);
42-
return code === "INVALID_QUERY_DATA" || code === "RESOURCE_NOT_FOUND";
43-
},
44-
4540
immediate: route.query.token !== undefined,
4641
4742
// Don't rerun the request when `route.query.token` changes. It can change to
4843
// `undefined`, which is invalid.
4944
watch: false,
45+
46+
catchApiErrors: {
47+
INVALID_QUERY_DATA: "silence",
48+
RESOURCE_NOT_FOUND: "silence",
49+
},
5050
});
5151
5252
watch(
@@ -77,26 +77,24 @@ const userId = ref<string>();
7777
async function submitNewPassword() {
7878
loading.value = true;
7979
80-
try {
81-
const passwordResponse = await api("/password-reset/password", {
82-
method: "POST",
83-
query: { token: route.query.token },
84-
body: { password: password.value },
85-
}).finally(() => {
86-
loading.value = false;
87-
});
88-
89-
setMe(passwordResponse.user);
90-
userId.value = passwordResponse.user.id;
91-
92-
page.value = "done";
93-
} catch (error) {
94-
if (getApiErrorCode(error) === "RESOURCE_NOT_FOUND") {
95-
page.value = "failed";
96-
} else {
97-
throw error;
98-
}
99-
}
80+
const passwordResponse = await api("/password-reset/password", {
81+
method: "POST",
82+
query: { token: route.query.token },
83+
body: { password: password.value },
84+
85+
catchApiErrors: {
86+
RESOURCE_NOT_FOUND: () => {
87+
page.value = "failed";
88+
},
89+
},
90+
}).finally(() => {
91+
loading.value = false;
92+
});
93+
94+
setMe(passwordResponse.user);
95+
userId.value = passwordResponse.user.id;
96+
97+
page.value = "done";
10098
}
10199
</script>
102100

frontend/pages/sign-in.vue

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,31 +16,29 @@ watch([email, password], () => {
1616
async function submitSignIn() {
1717
loading.value = true;
1818
19-
try {
20-
const session = await api("/sessions", {
21-
method: "POST",
22-
body: {
23-
email: email.value,
24-
password: password.value,
19+
const session = await api("/sessions", {
20+
method: "POST",
21+
body: {
22+
email: email.value,
23+
password: password.value,
24+
},
25+
26+
catchApiErrors: {
27+
USER_CREDENTIALS_WRONG: () => {
28+
areCredentialsWrong.value = true;
2529
},
26-
}).finally(() => {
27-
loading.value = false;
28-
});
29-
30-
setMe(session.user);
31-
32-
await useRedirectIfSignedIn({
33-
onBeforeRedirect() {
34-
loading.value = true;
35-
},
36-
});
37-
} catch (error) {
38-
if (getApiErrorCode(error) === "USER_CREDENTIALS_WRONG") {
39-
areCredentialsWrong.value = true;
40-
} else {
41-
throw error;
42-
}
43-
}
30+
},
31+
}).finally(() => {
32+
loading.value = false;
33+
});
34+
35+
setMe(session.user);
36+
37+
await useRedirectIfSignedIn({
38+
onBeforeRedirect() {
39+
loading.value = true;
40+
},
41+
});
4442
}
4543
</script>
4644

frontend/pages/sign-up.vue

Lines changed: 44 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,12 @@ const codeResponse = await useApi("/email-verification/code", {
7272
method: "POST",
7373
query: { token: route.query.token },
7474
75-
shouldIgnoreResponseError: (error) => {
76-
const code = getApiErrorCode(error);
77-
return code === "INVALID_QUERY_DATA" || code === "RESOURCE_NOT_FOUND";
78-
},
79-
8075
immediate: route.query.token !== undefined,
76+
77+
catchApiErrors: {
78+
INVALID_QUERY_DATA: "silence",
79+
RESOURCE_NOT_FOUND: "silence",
80+
},
8181
});
8282
8383
watchEffect(() => {
@@ -110,27 +110,25 @@ watch(code, (code) => {
110110
async function submitCode(event: Event) {
111111
loading.value = true;
112112
113-
try {
114-
await api("/email-verification", {
115-
query: {
116-
email: email.value,
117-
code: code.value.toUpperCase(),
113+
await api("/email-verification", {
114+
query: {
115+
email: email.value,
116+
code: code.value.toUpperCase(),
117+
},
118+
119+
catchApiErrors: {
120+
RESOURCE_NOT_FOUND: () => {
121+
isCodeWrong.value = true;
122+
123+
const form = event.target as HTMLFormElement;
124+
form.getElementsByTagName("input")[0]?.select();
118125
},
119-
}).finally(() => {
120-
loading.value = false;
121-
});
126+
},
127+
}).finally(() => {
128+
loading.value = false;
129+
});
122130
123-
page.value = "final";
124-
} catch (error) {
125-
if (getApiErrorCode(error) === "RESOURCE_NOT_FOUND") {
126-
isCodeWrong.value = true;
127-
128-
const form = event.target as HTMLFormElement;
129-
form.getElementsByTagName("input")[0]?.select();
130-
} else {
131-
throw error;
132-
}
133-
}
131+
page.value = "final";
134132
}
135133
136134
function tryAgain() {
@@ -145,33 +143,31 @@ const confirmPassword = ref("");
145143
async function completeSignUp() {
146144
loading.value = true;
147145
148-
try {
149-
const user = await api("/users", {
150-
method: "POST",
151-
body: {
152-
email: email.value,
153-
emailVerificationCode: code.value.toUpperCase(),
154-
name: name.value,
155-
password: password.value,
146+
const user = await api("/users", {
147+
method: "POST",
148+
body: {
149+
email: email.value,
150+
emailVerificationCode: code.value.toUpperCase(),
151+
name: name.value,
152+
password: password.value,
153+
},
154+
155+
catchApiErrors: {
156+
EMAIL_VERIFICATION_CODE_WRONG: () => {
157+
isCodeWrong.value = true;
156158
},
157-
}).finally(() => {
158-
loading.value = false;
159-
});
159+
},
160+
}).finally(() => {
161+
loading.value = false;
162+
});
160163
161-
setMe(user);
164+
setMe(user);
162165
163-
await useRedirectIfSignedIn({
164-
onBeforeRedirect() {
165-
loading.value = true;
166-
},
167-
});
168-
} catch (error) {
169-
if (getApiErrorCode(error) === "EMAIL_VERIFICATION_CODE_WRONG") {
170-
isCodeWrong.value = true;
171-
} else {
172-
throw error;
173-
}
174-
}
166+
await useRedirectIfSignedIn({
167+
onBeforeRedirect() {
168+
loading.value = true;
169+
},
170+
});
175171
}
176172
</script>
177173

frontend/pages/verify-email.vue

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ const { data: email } = await useApi("/email-verification", {
99
1010
transform: (emailVerification) => emailVerification.email ?? "",
1111
12-
shouldIgnoreResponseError: (error) => {
13-
const code = getApiErrorCode(error);
14-
return code === "INVALID_QUERY_DATA" || code === "RESOURCE_NOT_FOUND";
12+
catchApiErrors: {
13+
INVALID_QUERY_DATA: "silence",
14+
RESOURCE_NOT_FOUND: "silence",
1515
},
1616
});
1717
@@ -23,22 +23,20 @@ const code = ref<string>();
2323
async function generateCode() {
2424
loading.value = true;
2525
26-
try {
27-
const codeResponse = await api("/email-verification/code", {
28-
method: "POST",
29-
query: { token: route.query.token },
30-
}).finally(() => {
31-
loading.value = false;
32-
});
33-
34-
code.value = codeResponse.code;
35-
} catch (error) {
36-
if (getApiErrorCode(error) === "RESOURCE_NOT_FOUND") {
37-
email.value = "";
38-
} else {
39-
throw error;
40-
}
41-
}
26+
const codeResponse = await api("/email-verification/code", {
27+
method: "POST",
28+
query: { token: route.query.token },
29+
30+
catchApiErrors: {
31+
RESOURCE_NOT_FOUND: () => {
32+
email.value = "";
33+
},
34+
},
35+
}).finally(() => {
36+
loading.value = false;
37+
});
38+
39+
code.value = codeResponse.code;
4240
}
4341
4442
function handleCodeInputClick(

0 commit comments

Comments
 (0)