javasciprt

Centralized Exception Handling in Supabase Edge Functions by Pranav Khandelwal

I have been building a full-stack mobile app with Supabase and wanted to centralize my exception handling into a single place.

I had a few goals with this implementation:

  1. I wanted a consistent response shape for all my errors

  2. I wanted to establish a clear pattern for error handling in my edge functions

  3. I wanted to centralize my error handling in one place

The general idea was that all my functions will throw exceptions and a global error handling middleware would capture and handle the exceptions as needed.

My implementation had 3 key components:

  1. An Error Response shape

  2. A CustomError class that extends Error

  3. A Global exception handling “middleware” to wrap all of my edge functions (taking inspiration from .NET)

I decided to model my Error Response like this:

export type ErrorResponse = {
    message: string;
    code: ErrorCode;
    metadata?: Record<string, string>;
};

export type ErrorCode =
    | "RELAY_ERROR"
    | "FETCH_ERROR"
    | "UNKNOWN_ERROR"
    | "UNAUTHORIZED"
    | "VALIDATION_ERROR"

All my API errors will return this in the body. The ErrorCode can be extended to add additional domain specific error codes that my app can use to conditionally execute logic, like rendering various messages or navigating to a specific route.

The metadata object can contain any additional information I wanted to pass back to the app.

Now that I had my shape defined, the next step was to implement my CustomError class:

class CustomError extends Error implements ErrorResponse {
    code: ErrorCode;
    status: number;
    metadata?: Record<string, string>;
    shouldLog?: boolean = false;
    constructor(
        error: string,
        code: ErrorCode,
        status: number,
        metadata?: Record<string, string>,
    ) {
        super(error);
        this.code = code;
        this.status = status;
        this.metadata = metadata;
    }

    toErrorResponse(): Response {
        const errorResponse: ErrorResponse = {
            message: this.message,
            code: this.code,
            metadata: this.metadata,
        };
        return new Response(JSON.stringify(errorResponse), {
            status: this.status,
            headers: {
                "Content-Type": "application/json",
            },
        });
    }

    toLogResponse(): string {
        return JSON.stringify(this.toErrorResponse(), null, 2);
    }
}

My CustomError class extends the base Error type. There are some helper functions to convert my CustomError to a Response object that can be returned from an edge function or a JSON string for logging.

I also added a shouldLog boolean to indicate to my middleware if the error should be logged to the console. Some errors, like validation errors, don’t need to be logged, so this flag helps avoid cluttering the logs with unnecessary lines.

The last step was to implement my middleware. The middleware was implemented as a higher-order-function that can wrap all my edge functions:

export const withGlobalErrorHandler = (
    handler: (req: Request) => Promise<Response>,
) => {
    return async (req: Request) => {
        try {
            return await handler(req);
        } catch (error) {
            logError(error);
            return error instanceof CustomError
                ? error.toErrorResponse()
                : new InternalServerError("Internal Server Error")
                    .toErrorResponse();
        }
    };
};

const logError = (error: unknown) => {
    if (error instanceof CustomError && error.shouldLog) 
        
     else if (error instanceof Error) 
        
     else {
        console.error(`Unknown error: $
    
};

Now, any exceptions thrown by my request handler will be captured here, logged if necessary, and handled appropriately. To use the middleware, I wrapped my edge functions like this:

Deno.serve(withGlobalErrorHandler(async (req) => {
  const  = await req.json() as { postId: string };
  
  const post = await getPost(postId);

  if (!post) {
    throw new CustomError("Post not found", "NOT_FOUND", 404);
  }

  return new Response(JSON.stringify(post), {
    headers: { "Content-Type": "application/json" },
  });
}));

Any exception thrown in the request handler will now be captured and handled by the middleware.

Closing Thoughts

There is a lot of room for improvement here. For instance, using exceptions for flow control is considered an anti-pattern. A better approach would be to use a Result type.

One obvious benefit of moving away from exceptions to something like a Result type is that the middleware can handle returning the API response for all possible outcomes: errors, unhandled exceptions, and successful results. Adding common headers, serializing the response, and other logic could then be centralized instead of duplicated across edge functions.

That being said, I like to be pragmatic instead of dogmatic. Since this is a hobby project, I just need something that is good enough to work for my use case. Software is built iteratively, so I can continue to improve the implementation overtime.