grain.social is a photo sharing platform built on atproto.
1import { OAUTH_ROUTES, RateLimitError, UnauthorizedError } from "@bigmoves/bff";
2import { formatDuration, intervalToDuration } from "date-fns";
3
4function errorResponse(message: string, status: number): Response {
5 return new Response(message, {
6 status,
7 headers: { "Content-Type": "text/plain; charset=utf-8" },
8 });
9}
10
11function jsonErrorResponse(err: XRPCError): Response {
12 return new Response(JSON.stringify(err.toJSON()), {
13 status: err.status,
14 headers: {
15 "Content-Type": "application/json",
16 },
17 });
18}
19
20export function onError(err: unknown): Response {
21 if (err instanceof XRPCError) {
22 return jsonErrorResponse(err);
23 }
24 if (err instanceof BadRequestError) {
25 return errorResponse(err.message, 400);
26 }
27 if (err instanceof ServerError) {
28 return errorResponse(err.message, 500);
29 }
30 if (err instanceof NotFoundError) {
31 return errorResponse(err.message, 404);
32 }
33 if (err instanceof UnauthorizedError) {
34 const ctx = err.ctx;
35 return ctx.redirect(OAUTH_ROUTES.loginPage);
36 }
37 if (err instanceof RateLimitError) {
38 const now = new Date();
39 const future = new Date(now.getTime() + (err.retryAfter ?? 0) * 1000);
40 const duration = intervalToDuration({ start: now, end: future });
41 const formatted = formatDuration(duration, {
42 format: ["minutes", "seconds"],
43 });
44 return new Response(
45 `Too many requests. Retry in ${formatted}.`,
46 {
47 status: 429,
48 headers: {
49 ...(err.retryAfter && { "Retry-After": err.retryAfter.toString() }),
50 "Content-Type": "text/plain; charset=utf-8",
51 },
52 },
53 );
54 }
55 console.error("Unhandled error:", err);
56 return errorResponse("Internal Server Error", 500);
57}
58
59export class NotFoundError extends Error {
60 constructor(message = "Not Found") {
61 super(message);
62 this.name = "NotFoundError";
63 }
64}
65
66export const ServerError = class extends Error {
67 constructor(message = "Internal Server Error") {
68 super(message);
69 this.name = "ServerError";
70 }
71};
72
73export class BadRequestError extends Error {
74 constructor(message: string = "Bad Request") {
75 super(message);
76 this.name = "BadRequestError";
77 }
78}
79
80const XRPCErrorCodes = {
81 InvalidRequest: 400,
82 NotFound: 404,
83 InternalServerError: 500,
84 AuthenticationRequired: 401,
85 PayloadTooLarge: 413,
86} as const;
87
88type XRPCErrorCode = keyof typeof XRPCErrorCodes;
89
90export class XRPCError extends Error {
91 code: XRPCErrorCode;
92 error?: unknown;
93
94 constructor(code: XRPCErrorCode, error?: unknown) {
95 super(typeof error === "string" ? error : code);
96 this.name = "XRPCError";
97 this.code = code;
98 this.error = error;
99 if (Error.captureStackTrace) {
100 Error.captureStackTrace(this, XRPCError);
101 }
102 }
103
104 get status(): number {
105 return XRPCErrorCodes[this.code];
106 }
107
108 toJSON() {
109 return {
110 error: this.code,
111 message: this.message,
112 };
113 }
114}