From d2d4a7e3da7926b1681d05b141394c454ada8ce0 Mon Sep 17 00:00:00 2001 From: Tyagiquamar Date: Fri, 11 Sep 2026 19:39:05 +0530 Subject: [PATCH] Reject with an HTTPError on non-200 responses execute() and uploadImage() rejected with the raw response body string when SerpApi returned a non-200 status code. The rejection had no HTTP status code, was not an Error, and lost the stack trace, so callers could not branch on the failure kind or log where it came from. Add an HTTPError class exposing statusCode and the raw body, with the message set to the error field of a JSON response when present, and reject with it in both request paths. This matches the structured HTTP errors already exposed by the serpapi-python (HTTPError.status_code) and serpapi-ruby (SerpApiError#response_status) SDKs. --- CHANGELOG.md | 5 +++++ README.md | 24 +++++++++++++++++++++ mod.ts | 1 + src/errors.ts | 37 ++++++++++++++++++++++++++++++++ src/utils.ts | 6 +++--- tests/errors_test.ts | 49 +++++++++++++++++++++++++++++++++++++++++++ tests/serpapi_test.ts | 8 +++++-- tests/utils_test.ts | 17 ++++++++++++++- 8 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 tests/errors_test.ts 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", + ); + }); }, );