Search code examples
jsontypescriptdenojson-rpc

JSON-RPC v2 API. HTTP Layer. What are the appropriate HTTP Layer status codes for API responses?


I've been working on a Deno script that implements a JSON-RPC v2 API using Deno.serve(), which has been recently stabilized. While I've set up the basic CRUD operations and error handling, I'm not sure about the HTTP status codes I should be using for different scenarios.

I go online and find that in JSON-RPC 2.0, the protocol itself does not strictly define the use of specific HTTP status codes. Instead, it focuses on the JSON payload to communicate success and error states. However, when JSON-RPC is transported over HTTP, there are some common conventions:

  • 200 OK: This is the most commonly used status code for JSON-RPC responses, both for successful calls and for calls that result in a JSON-RPC error. The distinction between a successful call and an error is made in the JSON payload itself, using the result and error fields.
  • 500 Internal Server Error: This can be used when there's a server error that prevents the JSON-RPC request from being processed. It's a general indication of a server-side issue.
  • 404 Not Found: While not common for method not found errors (which are typically communicated with a 200 OK and the -32601 Method not found error in the payload), this status can be used if the JSON-RPC endpoint itself is not found.
  • 400 Bad Request: This can be used for malformed requests, where the JSON is not valid or the request doesn't conform to the JSON-RPC format.

I've used the following status codes:

  • 200 for successful operations
  • 400 for client errors (like invalid parameters)
  • 404 for not found routes
  • 501 for not implemented methods

Can someone clarify or provide a reference on which status codes are appropriate for different JSON-RPC v2 scenarios? Is it really just these 4?

What about 201, 204, 401, 403, 405, 415, 503? This the part where I get confused with HTTP being used as a transport layer and me overthinking semantics...

Here is the code:

// Configuration (read-only!)
const config = {
  rpc: {
    service: {
      hostname: "0.0.0.0",
      port: 3000,
      routes: {
        root: "/service",
        service: "/service/v2",
        status: "/service/v2/status",
        docs: "/service/v2/docs",
      },
    },
  },
} as const;

// Config Type definition
type Config = typeof config;

// DataStore Type definition
type DataStore = {
  [key: string]: any;
};

// Params Type definitions
type CreateParams = {
  id: string;
  name?: string;
  age?: number;
};

type ReadParams = {
  id: string;
  name?: string;
  age?: number;
};

type ListParams = {
  startIndex: number;
  endIndex: number;
};

type UpdateParams = {
  id: string;
  name?: string;
  age?: number;
};

type DeleteParams = {
  id: string;
  name?: string;
  age?: number;
};

// Data store for CRUD operations
const DATA_STORE: DataStore = {};

// CRUD methods
const methods = {
  // METHOD -> CREATE
  create: (params: CreateParams) => {
    const { id } = params;
    if (DATA_STORE[id]) {
      throw new Error("ID already exists");
    }
    DATA_STORE[id] = params;
    return [{ success: true }];
  },
  // METHOD -> READ
  read: (params: ReadParams) => {
    const { id } = params;
    if (!DATA_STORE[id]) {
      throw new Error("ID not found");
    }
    return [DATA_STORE[id]];
  },
  // METHOD -> LIST
  list: (params: ListParams) => {
    const { startIndex, endIndex } = params;
    if (startIndex === undefined || endIndex === undefined) {
      throw new Error(
        "Both startIndex and endIndex are required for the list method",
      );
    }
    return Object.entries(DATA_STORE)
      .slice(startIndex, endIndex + 1)
      .map(([key, value]) => ({ [key]: value }));
  },
  // METHOD -> UPDATE
  update: (params: UpdateParams) => {
    const { id } = params;
    if (!DATA_STORE[id]) {
      throw new Error("ID not found");
    }
    DATA_STORE[id] = params;
    return [{ success: true }];
  },
  // METHOD -> DELETE
  delete: (params: DeleteParams) => {
    const { id } = params;
    if (!DATA_STORE[id]) {
      throw new Error("ID not found");
    }
    delete DATA_STORE[id];
    return [{ success: true }];
  },
};

// REQUEST -> HANDLER
async function handler(request: Request): Promise<Response> {
  const reqUrl = request.url as string;
  const { pathname } = new URL(reqUrl);

  if (pathname !== config.rpc.service.routes.service) {
    return new Response("HTTP 404: Not Found", { status: 404 });
  }

  const reqBody = await request.json();
  const { method, params } = reqBody;

  switch (method) {
    // CASE -> method.create()
    case "create":
      try {
        const result = methods.create(params[0]);

        const createSuccess = {
          jsonrpc: "2.0",
          id: "request-id",
          result: [
            {
              success: true,
            },
          ],
        };

        return new Response(JSON.stringify(createSuccess), { status: 200 });
      } catch (error) {
        const createError = {
          jsonrpc: "2.0",
          id: "request-id",
          error: {
            code: -32000,
            // message: "Error message describing the nature of the error",
            message: error.message,
            data: "Optional data about the error",
          },
        };

        return new Response(JSON.stringify(createError), { status: 400 });
      }

    // CASE -> method.read()
    case "read":
      try {
        const result = methods.read(params[0]);

        const readSuccess = {
          jsonrpc: "2.0",
          id: "request-id",
          result: result,
        };

        return new Response(
          JSON.stringify(readSuccess),
          { status: 200 },
        );
      } catch (error) {
        const readError = {
          jsonrpc: "2.0",
          id: "request-id",
          error: {
            code: -32000,
            // message: "Error message describing the nature of the error",
            message: error.message,
            data: "Optional data about the error",
          },
        };

        return new Response(JSON.stringify(readError), { status: 400 });
      }

    // CASE -> method.list()
    case "list":
      try {
        const result = methods.list(params[0]);

        const listSuccess = {
          jsonrpc: "2.0",
          id: "request-id",
          result: result,
        };

        return new Response(JSON.stringify(listSuccess), { status: 200 });
      } catch (error) {
        const listError = {
          jsonrpc: "2.0",
          id: "request-id",
          error: {
            code: -32000,
            // message: "Error message describing the nature of the error",
            message: error.message,
            data: "Optional data about the error",
          },
        };

        return new Response(JSON.stringify(listError), { status: 400 });
      }

    // CASE -> method.update()
    case "update":
      try {
        const result = methods.update(params[0]);

        const updateSuccess = {
          jsonrpc: "2.0",
          id: "request-id",
          result: [
            {
              success: true,
            },
          ],
        };

        return new Response(JSON.stringify(updateSuccess), { status: 200 });
      } catch (error) {
        const updateError = {
          jsonrpc: "2.0",
          id: "request-id",
          error: {
            code: -32000,
            // message: "Error message describing the nature of the error",
            message: error.message,
            data: "Optional data about the error",
          },
        };

        return new Response(JSON.stringify(updateError), { status: 400 });
      }

    // CASE -> method.delete()
    case "delete":
      try {
        const result = methods.delete(params[0]);

        const deleteSuccess = {
          jsonrpc: "2.0",
          id: "request-id",
          result: result,
        };

        return new Response(JSON.stringify(deleteSuccess), { status: 200 });
      } catch (error) {
        const deleteError = {
          jsonrpc: "2.0",
          id: "request-id",
          error: {
            code: -32000,
            // message: "Error message describing the nature of the error",
            message: error.message,
            data: "Optional data about the error",
          },
        };

        return new Response(JSON.stringify(deleteError), { status: 400 });
      }

    default:
      // CASE -> method unknown
      const rpcMethodError = {
        jsonrpc: "2.0",
        id: "request-id",
        error: {
          code: -32000,
          // message: "Error message describing the nature of the error",
          message: "RPC Method not implemented.",
          data: "Optional data about the error",
        },
      };

      return new Response(JSON.stringify(rpcMethodError), { status: 501 });
  }
}

// Setup and start the Deno server
Deno.serve({
  port: config.rpc.service.port,
  hostname: config.rpc.service.hostname,
  onListen({ port, hostname }) {
    console.log(
      `%cDeno v${Deno.version.deno} : Typescript v${Deno.version.typescript} : V8 v${Deno.version.v8}
Application: Deno JSON RPCv2 Server based on OpenRPC specification
Permissions: --allow-net=${hostname}:${port}
Gateway URL: http://${hostname}:${port}
Root: http://${hostname}:${port}${config.rpc.service.routes.root}
Service: http://${hostname}:${port}${config.rpc.service.routes.service}
Status: http://${hostname}:${port}${config.rpc.service.routes.status}
Docs: http://${hostname}:${port}${config.rpc.service.routes.docs}`,
      "color: #7986cb",
    );
  },
  onError(error: unknown) {
    console.error(error);
    return new Response("HTTP 500: Internal Server Error", {
      status: 500,
      headers: { "content-type": "text/plain" },
    });
  },
}, handler);

Solution

  • Best practice is to use a 200 status code for all errors that are generated by the method itself rather than by the http server's attempt to call the method. So, for example, trying to create a resource that already exists would be a 200 status, and an HTTP authentication problem would be a 403.

    This is a lot easier, of course, if you structure your code so that the method handlers are abstracted into a separate layer from the handlers that deal directly with the details of http. In other words, the method handlers should be transport-independent, at least conceptually.

    Here are some references that I found:

    The gist of both of these is that non-200 codes are for indicating a problem with the HTTP transport, and not for errors that are related to the semantics of the method call.

    Here's a document from the "historical" section of the JSON-RPC web site:

    It's broadly in agreement with the principle I've outlined above.