Skip to content

Commit 844746c

Browse files
committed
(thunderstore-api) Implement user-facing error handling improvements and update tests for authentication errors
1 parent 2d3f2a3 commit 844746c

File tree

5 files changed

+265
-32
lines changed

5 files changed

+265
-32
lines changed

packages/thunderstore-api/src/apiFetch.ts

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const BASE_HEADERS = {
1616
const MAX_NB_RETRY = 5;
1717
const RETRY_DELAY_MS = 200;
1818

19+
/**
20+
* Attempts a fetch call multiple times to work around transient network failures.
21+
*/
1922
async function fetchRetry(
2023
input: RequestInfo | URL,
2124
init?: RequestInit | undefined
@@ -39,32 +42,44 @@ async function fetchRetry(
3942
}
4043
}
4144

45+
/**
46+
* Simple timeout helper used by the retry loop.
47+
*/
4248
function sleep(delay: number) {
4349
return new Promise((resolve) => setTimeout(resolve, delay));
4450
}
4551

46-
export type apiFetchArgs<B, QP> = {
52+
/**
53+
* Arguments supplied to `apiFetch` describing the HTTP request and validation schemas.
54+
*/
55+
type SchemaOrUndefined<Schema extends z.ZodSchema | undefined> =
56+
Schema extends z.ZodSchema ? z.infer<Schema> : undefined;
57+
58+
export type apiFetchArgs<
59+
RequestSchema extends z.ZodSchema | undefined,
60+
QueryParamsSchema extends z.ZodSchema | undefined,
61+
> = {
4762
config: () => RequestConfig;
4863
path: string;
49-
queryParams?: QP;
64+
queryParams?: SchemaOrUndefined<QueryParamsSchema>;
5065
request?: Omit<RequestInit, "headers" | "body"> & { body?: string };
5166
useSession?: boolean;
52-
bodyRaw?: B;
67+
bodyRaw?: SchemaOrUndefined<RequestSchema>;
5368
};
5469

55-
type schemaOrUndefined<A> = A extends z.ZodSchema
56-
? z.infer<A>
57-
: never | undefined;
58-
59-
export async function apiFetch(props: {
60-
args: apiFetchArgs<
61-
schemaOrUndefined<typeof props.requestSchema>,
62-
schemaOrUndefined<typeof props.queryParamsSchema>
63-
>;
64-
requestSchema: z.ZodSchema | undefined;
65-
queryParamsSchema: z.ZodSchema | undefined;
66-
responseSchema: z.ZodSchema | undefined;
67-
}): Promise<schemaOrUndefined<typeof props.responseSchema>> {
70+
/**
71+
* Validates input payloads, executes the HTTP request, and parses the response with Zod schemas.
72+
*/
73+
export async function apiFetch<
74+
RequestSchema extends z.ZodSchema | undefined,
75+
QueryParamsSchema extends z.ZodSchema | undefined,
76+
ResponseSchema extends z.ZodSchema | undefined,
77+
>(props: {
78+
args: apiFetchArgs<RequestSchema, QueryParamsSchema>;
79+
requestSchema: RequestSchema;
80+
queryParamsSchema: QueryParamsSchema;
81+
responseSchema: ResponseSchema;
82+
}): Promise<SchemaOrUndefined<ResponseSchema>> {
6883
const { args, requestSchema, queryParamsSchema, responseSchema } = props;
6984

7085
if (requestSchema && args.bodyRaw) {
@@ -73,6 +88,7 @@ export async function apiFetch(props: {
7388
throw new RequestBodyParseError(parsedRequestBody.error);
7489
}
7590
}
91+
7692
if (queryParamsSchema && args.queryParams) {
7793
const parsedQueryParams = queryParamsSchema.safeParse(args.queryParams);
7894
if (!parsedQueryParams.success) {
@@ -81,14 +97,14 @@ export async function apiFetch(props: {
8197
}
8298

8399
const { config, path, request, queryParams, useSession = false } = args;
100+
const configSnapshot = config();
84101
const usedConfig: RequestConfig = useSession
85-
? config()
102+
? configSnapshot
86103
: {
87-
apiHost: config().apiHost,
104+
apiHost: configSnapshot.apiHost,
88105
sessionId: undefined,
89106
};
90-
// TODO: Query params have stronger types, but they are not just shown here.
91-
// Look into furthering the ensuring of passing proper query params.
107+
const sessionWasUsed = Boolean(usedConfig.sessionId);
92108
const url = getUrl(usedConfig, path, queryParams);
93109

94110
const response = await fetchRetry(url, {
@@ -100,19 +116,27 @@ export async function apiFetch(props: {
100116
});
101117

102118
if (!response.ok) {
103-
throw await ApiError.createFromResponse(response);
119+
const apiError = await ApiError.createFromResponse(response, {
120+
sessionWasUsed,
121+
});
122+
throw apiError;
104123
}
105124

106-
if (responseSchema === undefined) return undefined;
125+
if (responseSchema === undefined) {
126+
return undefined as SchemaOrUndefined<ResponseSchema>;
127+
}
107128

108129
const parsed = responseSchema.safeParse(await response.json());
109130
if (!parsed.success) {
110131
throw new ParseError(parsed.error);
111-
} else {
112-
return parsed.data;
113132
}
133+
134+
return parsed.data;
114135
}
115136

137+
/**
138+
* Derives authentication headers based on the provided request configuration.
139+
*/
116140
function getAuthHeaders(config: RequestConfig): RequestInit["headers"] {
117141
return config.sessionId
118142
? {
@@ -121,6 +145,9 @@ function getAuthHeaders(config: RequestConfig): RequestInit["headers"] {
121145
: {};
122146
}
123147

148+
/**
149+
* Builds the request URL with optional query parameters.
150+
*/
124151
function getUrl(
125152
config: RequestConfig,
126153
path: string,

0 commit comments

Comments
 (0)