diff --git a/CHANGELOG.md b/CHANGELOG.md index 05f5a1e..9c53e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to `getMdBySearchId`. - Expose `EngineParameters` type. - Expose `InvalidArgumentError` error. +- Expose `HTTPError` error raised on non-200 responses, with `statusCode` and + `body` properties. ### Changed @@ -23,6 +25,9 @@ and this project adheres to ### Fixed +- Reject with an `HTTPError` instead of the raw response body when SerpApi + responds with a non-200 status code. + ### Removed - Remove all types for engine parameters and responses. SerpApi's diff --git a/README.md b/README.md index 9d29f15..cda777d 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,30 @@ await getJson({ engine: "google", q: "coffee" }); // uses the API key defined in await getJson({ engine: "google", api_key: API_KEY_2, q: "coffee" }); // API_KEY_2 will be used ``` +### Error handling + +When SerpApi responds with a non-200 status code, the returned promise rejects +with an `HTTPError`. It exposes the response `statusCode` and the raw `body`, +and its message is set to the `error` field of the response when one is present. + +```js +import { getJson, HTTPError } from "serpapi"; + +try { + const json = await getJson({ + engine: "google", + api_key: API_KEY, + q: "coffee", + }); +} catch (error) { + if (error instanceof HTTPError) { + console.log(error.statusCode); // e.g. 401 + console.log(error.body); // e.g. '{"error":"Invalid API key. ..."}' + console.log(error.message); // e.g. 'Invalid API key. ...' + } +} +``` + ### Using a Proxy > **Note:** SerpApi handles proxies on its end — you do **not** need to supply diff --git a/mod.ts b/mod.ts index 8006a63..ad7d4d4 100644 --- a/mod.ts +++ b/mod.ts @@ -2,6 +2,7 @@ export type { Config } from "./src/config.ts"; export { config } from "./src/config.ts"; export { + HTTPError, InvalidArgumentError, InvalidTimeoutError, MissingApiKeyError, diff --git a/src/errors.ts b/src/errors.ts index be53b73..ad7d38a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -27,3 +27,40 @@ export class RequestTimeoutError extends Error { Object.setPrototypeOf(this, RequestTimeoutError.prototype); } } + +/** + * Error raised when SerpApi responds with a non-200 status code. + * + * @property {number} statusCode HTTP status code of the response. + * @property {string} body Raw response body. + * @example + * try { + * const json = await getJson({ engine: "google", api_key: API_KEY, q: "coffee" }); + * } catch (error) { + * if (error instanceof HTTPError) { + * console.log(error.statusCode, error.body); + * } + * } + */ +export class HTTPError extends Error { + readonly statusCode: number | undefined; + readonly body: string; + + constructor(statusCode: number | undefined, body: string) { + let message = `Request failed with status code ${statusCode}`; + try { + const parsed = JSON.parse(body) as { error?: string }; + if ( + parsed && typeof parsed.error === "string" && parsed.error.length > 0 + ) { + message = parsed.error; + } + } catch { + // The body is not JSON. Fall back to the status code message. + } + super(message); + this.statusCode = statusCode; + this.body = body; + Object.setPrototypeOf(this, HTTPError.prototype); + } +} diff --git a/src/utils.ts b/src/utils.ts index 082434c..db1d50c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,7 +3,7 @@ import https from "node:https"; import http from "node:http"; import qs from "node:querystring"; import process from "node:process"; -import { RequestTimeoutError } from "./errors.ts"; +import { HTTPError, RequestTimeoutError } from "./errors.ts"; import { config } from "./config.ts"; import { createMultipartBody } from "./multipart.ts"; @@ -99,7 +99,7 @@ export function execute( if (resp.statusCode == 200) { resolve(data); } else { - reject(data); + reject(new HTTPError(resp.statusCode, data)); } } catch (e) { reject(e); @@ -170,7 +170,7 @@ export function uploadImage( resp.on("end", () => { if (timer) clearTimeout(timer); if (resp.statusCode === 200) resolve(data); - else reject(data); + else reject(new HTTPError(resp.statusCode, data)); }); }); req.on("error", (error) => { diff --git a/tests/errors_test.ts b/tests/errors_test.ts new file mode 100644 index 0000000..11076b2 --- /dev/null +++ b/tests/errors_test.ts @@ -0,0 +1,49 @@ +import { describe, it } from "@std/testing/bdd"; +import { assertEquals, assertInstanceOf } from "@std/testing/asserts"; +import { HTTPError } from "../src/errors.ts"; + +describe("HTTPError", () => { + it("with JSON body containing an error field", () => { + const error = new HTTPError( + 401, + '{"error":"Invalid API key. Your API key should be here: https://serpapi.com/manage-api-key"}', + ); + assertInstanceOf(error, HTTPError); + assertInstanceOf(error, Error); + assertEquals(error.statusCode, 401); + assertEquals( + error.body, + '{"error":"Invalid API key. Your API key should be here: https://serpapi.com/manage-api-key"}', + ); + assertEquals( + error.message, + "Invalid API key. Your API key should be here: https://serpapi.com/manage-api-key", + ); + }); + + it("with JSON body without an error field", () => { + const error = new HTTPError(404, '{"foo":"bar"}'); + assertEquals(error.statusCode, 404); + assertEquals(error.body, '{"foo":"bar"}'); + assertEquals(error.message, "Request failed with status code 404"); + }); + + it("with non-JSON body", () => { + const error = new HTTPError(500, "Internal Server Error"); + assertEquals(error.statusCode, 500); + assertEquals(error.body, "Internal Server Error"); + assertEquals(error.message, "Request failed with status code 500"); + }); + + it("with empty JSON error field", () => { + const error = new HTTPError(400, '{"error":""}'); + assertEquals(error.message, "Request failed with status code 400"); + }); + + it("with empty body", () => { + const error = new HTTPError(502, ""); + assertEquals(error.statusCode, 502); + assertEquals(error.body, ""); + assertEquals(error.message, "Request failed with status code 502"); + }); +}); diff --git a/tests/serpapi_test.ts b/tests/serpapi_test.ts index 99cc821..06f1f5c 100644 --- a/tests/serpapi_test.ts +++ b/tests/serpapi_test.ts @@ -28,6 +28,7 @@ import { getLocations, getMd, getMdBySearchId, + HTTPError, InvalidArgumentError, InvalidTimeoutError, MissingApiKeyError, @@ -248,12 +249,15 @@ describe("uploadImage", () => { const executeStub = stub( _internals, "uploadImage", - () => Promise.reject(apiError), + () => Promise.reject(new HTTPError(401, apiError)), ); config.api_key = "test_api_key"; try { const error = await uploadImage({ image }).catch((error) => error); - assertEquals(error, apiError); + assertInstanceOf(error, HTTPError); + assertEquals(error.statusCode, 401); + assertEquals(error.body, apiError); + assertEquals(error.message, "Invalid image"); } finally { executeStub.restore(); } diff --git a/tests/utils_test.ts b/tests/utils_test.ts index 46f444d..aaa9bc7 100644 --- a/tests/utils_test.ts +++ b/tests/utils_test.ts @@ -14,7 +14,7 @@ import { execute, getSource, } from "../src/utils.ts"; -import { RequestTimeoutError } from "../src/errors.ts"; +import { HTTPError, RequestTimeoutError } from "../src/errors.ts"; import { Config, config } from "../src/config.ts"; loadSync({ export: true }); @@ -233,5 +233,20 @@ describe( assertInstanceOf(e, RequestTimeoutError); } }); + + it("with error response", async () => { + const error = await execute( + "/account", + { api_key: "invalid_api_key" }, + 20000, + ).catch((error) => error); + assertInstanceOf(error, HTTPError); + assertEquals(error.statusCode, 401); + assertMatch(error.body, /"error"/); + assertEquals( + error.message, + "Invalid API key. Your API key should be here: https://serpapi.com/manage-api-key", + ); + }); }, );