diff --git a/CHANGELOG.md b/CHANGELOG.md index 47dc61a..42cc976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## main +### Added + +- Add rate limit information to all API responses via `rateLimit` property, exposing `limit`, `remaining`, and `reset` values from response headers + ## 11.0.0 - 2025-08-20 ### Changed diff --git a/lib/fetcher/fetch-fetcher.ts b/lib/fetcher/fetch-fetcher.ts index edab4d9..02809a2 100644 --- a/lib/fetcher/fetch-fetcher.ts +++ b/lib/fetcher/fetch-fetcher.ts @@ -1,5 +1,20 @@ import { DNSimple, TimeoutError } from "../main"; -import type { Fetcher } from "./fetcher"; +import type { Fetcher, RateLimitHeaders } from "./fetcher"; + +function parseRateLimitHeaders(headers: Headers): RateLimitHeaders { + const parseHeader = (name: string): number | null => { + const value = headers.get(name); + if (value === null) return null; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? null : parsed; + }; + + return { + limit: parseHeader("X-RateLimit-Limit"), + remaining: parseHeader("X-RateLimit-Remaining"), + reset: parseHeader("X-RateLimit-Reset"), + }; +} const fetchFetcher: Fetcher = async (params: { method: string; @@ -17,7 +32,11 @@ const fetchFetcher: Fetcher = async (params: { body: params.body, signal: abortController.signal, }); - return { status: response.status, body: await response.text() }; + return { + status: response.status, + body: await response.text(), + rateLimit: parseRateLimitHeaders(response.headers), + }; } catch (error) { if (abortController && abortController.signal.aborted) throw new TimeoutError(); diff --git a/lib/fetcher/fetcher.ts b/lib/fetcher/fetcher.ts index cf1c351..3aaa1d7 100644 --- a/lib/fetcher/fetcher.ts +++ b/lib/fetcher/fetcher.ts @@ -1,6 +1,15 @@ +/** + * Rate limit headers returned by the DNSimple API. + */ +export type RateLimitHeaders = { + limit: number | null; + remaining: number | null; + reset: number | null; +}; + /** * A function that makes an HTTP request. It's responsible for throwing {@link TimeoutError} and aborting the request on {@param params.timeout}. - * It should return the response status and full body as a string. It should not throw on any status, even if 4xx or 5xx. + * It should return the response status, full body as a string, and rate limit headers. It should not throw on any status, even if 4xx or 5xx. * It can decide to implement retries as appropriate. The default fetcher currently does not implement any retry strategy. */ export type Fetcher = (params: { @@ -12,6 +21,7 @@ export type Fetcher = (params: { }) => Promise<{ status: number; body: string; + rateLimit: RateLimitHeaders; }>; let fetcherImports: { diff --git a/lib/fetcher/https-fetcher.ts b/lib/fetcher/https-fetcher.ts index 2675090..dd2b737 100644 --- a/lib/fetcher/https-fetcher.ts +++ b/lib/fetcher/https-fetcher.ts @@ -1,7 +1,26 @@ +import * as http from "http"; import * as https from "https"; import { URL } from "url"; import { TimeoutError } from "../main"; -import type { Fetcher } from "./fetcher"; +import type { Fetcher, RateLimitHeaders } from "./fetcher"; + +function parseRateLimitHeaders( + headers: http.IncomingHttpHeaders +): RateLimitHeaders { + const parseHeader = (name: string): number | null => { + const value = headers[name]; + if (value === undefined) return null; + const strValue = Array.isArray(value) ? value[0] : value; + const parsed = parseInt(strValue, 10); + return isNaN(parsed) ? null : parsed; + }; + + return { + limit: parseHeader("x-ratelimit-limit"), + remaining: parseHeader("x-ratelimit-remaining"), + reset: parseHeader("x-ratelimit-reset"), + }; +} const httpsFetcher: Fetcher = (params: { method: string; @@ -9,7 +28,7 @@ const httpsFetcher: Fetcher = (params: { headers: { [name: string]: string }; timeout: number; body?: string; -}): Promise<{ status: number; body: string }> => { +}) => { return new Promise((resolve, reject) => { const urlObj = new URL(params.url); const options: https.RequestOptions = { @@ -27,6 +46,7 @@ const httpsFetcher: Fetcher = (params: { resolve({ status: res.statusCode || 500, // Fallback to 500 if statusCode is undefined body: body, + rateLimit: parseRateLimitHeaders(res.headers), }); }); }); diff --git a/lib/main.ts b/lib/main.ts index 042cc2f..313c1cf 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -158,7 +158,11 @@ export class DNSimple { "Content-Type": "application/json", "User-Agent": `${this.userAgent} ${DNSimple.DEFAULT_USER_AGENT}`.trim(), }; - const { status, body: data } = await this.fetcher({ + const { + status, + body: data, + rateLimit, + } = await this.fetcher({ url: this.baseUrl + versionedPath(path, params), method, headers, @@ -181,10 +185,11 @@ export class DNSimple { throw new ClientError(status, JSON.parse(data)); } if (status === 204) { - return {}; + return { rateLimit }; } if (status >= 200 && status < 300) { - return !data ? {} : JSON.parse(data); + const parsed = !data ? {} : JSON.parse(data); + return { ...parsed, rateLimit }; } if (status >= 500) { throw new ServerError(status, JSON.parse(data)); diff --git a/lib/types.ts b/lib/types.ts index 7ca885c..6726db9 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,3 +1,9 @@ +export type RateLimit = { + limit: number | null; + remaining: number | null; + reset: number | null; +}; + export type Account = { id: number; email: string; diff --git a/test/accounts.spec.ts b/test/accounts.spec.ts index 95dfeff..53e5925 100644 --- a/test/accounts.spec.ts +++ b/test/accounts.spec.ts @@ -13,17 +13,16 @@ describe("accounts", () => { const result = await dnsimple.accounts.listAccounts(); - expect(result).toEqual({ - data: [ - { - id: 123, - email: "john@example.com", - plan_identifier: "dnsimple-personal", - created_at: "2011-09-11T17:15:58Z", - updated_at: "2016-06-03T15:02:26Z", - }, - ], - }); + expect(result.data).toEqual([ + { + id: 123, + email: "john@example.com", + plan_identifier: "dnsimple-personal", + created_at: "2011-09-11T17:15:58Z", + updated_at: "2016-06-03T15:02:26Z", + }, + ]); + expect(result.rateLimit).toBeDefined(); }); }); }); diff --git a/test/client.spec.ts b/test/client.spec.ts index 33e13ba..93deca3 100644 --- a/test/client.spec.ts +++ b/test/client.spec.ts @@ -1,9 +1,57 @@ import fetchMock from "fetch-mock"; -import { ClientError, MethodNotAllowedError } from "../lib/main"; +import { ClientError, MethodNotAllowedError, RateLimit } from "../lib/main"; import { createTestClient, responseFromFixture } from "./util"; const dnsimple = createTestClient(); +describe("rate limit", () => { + describe("when rate limit headers are present", () => { + it("exposes the rate limit info", async () => { + fetchMock.get( + "https://api.dnsimple.com/v2/1010/domains", + responseFromFixture("listDomains/success.http") + ); + + const response = await dnsimple.domains.listDomains(1010); + + const rateLimit = response.rateLimit as RateLimit; + expect(rateLimit).toBeDefined(); + expect(rateLimit.limit).toBe(2400); + expect(rateLimit.remaining).toBe(2399); + expect(rateLimit.reset).toBe(1591304056); + }); + }); + + describe("when rate limit headers are missing", () => { + it("returns null values", async () => { + fetchMock.get("https://api.dnsimple.com/v2/1010/test", { + status: 200, + body: { data: {} }, + }); + + const response = await dnsimple.request("GET", "/1010/test", null, {}); + + expect(response.rateLimit).toBeDefined(); + expect(response.rateLimit.limit).toBeNull(); + expect(response.rateLimit.remaining).toBeNull(); + expect(response.rateLimit.reset).toBeNull(); + }); + }); + + describe("on a 204 response", () => { + it("exposes the rate limit info", async () => { + fetchMock.delete( + "https://api.dnsimple.com/v2/1010/domains/example.com", + responseFromFixture("deleteDomain/success.http") + ); + + const response = await dnsimple.domains.deleteDomain(1010, "example.com"); + + expect(response.rateLimit).toBeDefined(); + }); + }); +}); + describe("response handling", () => { describe("a 400 error", () => { it("includes the error message from the server", async () => { diff --git a/test/contacts.spec.ts b/test/contacts.spec.ts index 05ef302..460144b 100644 --- a/test/contacts.spec.ts +++ b/test/contacts.spec.ts @@ -285,7 +285,7 @@ describe("contacts", () => { contactId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); describe("when the contact does not exist", () => { diff --git a/test/domain_delegation_signer_records.spec.ts b/test/domain_delegation_signer_records.spec.ts index 697bd39..5694100 100644 --- a/test/domain_delegation_signer_records.spec.ts +++ b/test/domain_delegation_signer_records.spec.ts @@ -210,7 +210,7 @@ describe("domains", () => { dsRecordId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); }); diff --git a/test/domain_dnssec.spec.ts b/test/domain_dnssec.spec.ts index ec3097a..7a2ba0a 100644 --- a/test/domain_dnssec.spec.ts +++ b/test/domain_dnssec.spec.ts @@ -46,7 +46,7 @@ describe("domains", () => { domainId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); diff --git a/test/domain_email_forwards.spec.ts b/test/domain_email_forwards.spec.ts index ec75a9a..3b84cab 100644 --- a/test/domain_email_forwards.spec.ts +++ b/test/domain_email_forwards.spec.ts @@ -208,7 +208,7 @@ describe("domains", () => { emailForwardId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); }); diff --git a/test/domain_pushes.spec.ts b/test/domain_pushes.spec.ts index 96c42da..e798378 100644 --- a/test/domain_pushes.spec.ts +++ b/test/domain_pushes.spec.ts @@ -90,7 +90,7 @@ describe("domains", () => { attributes ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); @@ -117,7 +117,7 @@ describe("domains", () => { const response = await dnsimple.domains.rejectPush(accountId, pushId); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); }); diff --git a/test/domain_services.spec.ts b/test/domain_services.spec.ts index 1437347..3ea92c4 100644 --- a/test/domain_services.spec.ts +++ b/test/domain_services.spec.ts @@ -109,7 +109,7 @@ describe("domain services", () => { {} ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); @@ -130,7 +130,7 @@ describe("domain services", () => { serviceId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); }); diff --git a/test/domains.spec.ts b/test/domains.spec.ts index fd61cc1..85a0f17 100644 --- a/test/domains.spec.ts +++ b/test/domains.spec.ts @@ -192,7 +192,7 @@ describe("domains", () => { const response = await dnsimple.domains.deleteDomain(accountId, domainId); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); }); diff --git a/test/registrar.spec.ts b/test/registrar.spec.ts index 437fbd6..bfb2519 100644 --- a/test/registrar.spec.ts +++ b/test/registrar.spec.ts @@ -311,7 +311,7 @@ describe("registrar", () => { domainId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); }); diff --git a/test/registrar_auto_renewal.spec.ts b/test/registrar_auto_renewal.spec.ts index a977645..8a6297c 100644 --- a/test/registrar_auto_renewal.spec.ts +++ b/test/registrar_auto_renewal.spec.ts @@ -20,7 +20,7 @@ describe("registrar auto renewal", () => { domainId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); describe("when the domain does not exist", () => { @@ -49,7 +49,7 @@ describe("registrar auto renewal", () => { domainId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); describe("when the domain does not exist", () => { diff --git a/test/registrar_domain_delegation.spec.ts b/test/registrar_domain_delegation.spec.ts index 648be46..592957d 100644 --- a/test/registrar_domain_delegation.spec.ts +++ b/test/registrar_domain_delegation.spec.ts @@ -89,7 +89,7 @@ describe("domain delegation", () => { domainId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); }); diff --git a/test/templates.spec.ts b/test/templates.spec.ts index 757f2dc..82504f4 100644 --- a/test/templates.spec.ts +++ b/test/templates.spec.ts @@ -236,7 +236,7 @@ describe("templates", () => { templateId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); @@ -257,7 +257,7 @@ describe("templates", () => { templateId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); }); @@ -483,7 +483,7 @@ describe("template records", () => { recordId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); }); diff --git a/test/util.ts b/test/util.ts index c55b1e0..b3fdd44 100644 --- a/test/util.ts +++ b/test/util.ts @@ -18,6 +18,23 @@ function parseStatusCode(lines: string[]) { return parseInt(lines[0].split(/\s+/)[1], 10); } +function parseHeaders(lines: string[]): { [key: string]: string } { + const headers: { [key: string]: string } = {}; + const separatorLineIndex = lines.findIndex((line) => line.trim() === ""); + + // Start from line 1 (skip status line) to the separator + for (let i = 1; i < separatorLineIndex; i++) { + const colonIndex = lines[i].indexOf(":"); + if (colonIndex > 0) { + const key = lines[i].slice(0, colonIndex).trim(); + const value = lines[i].slice(colonIndex + 1).trim(); + headers[key] = value; + } + } + + return headers; +} + function parseBody(lines: string[]) { const separatorLineIndex = lines.findIndex((line) => line.trim() === ""); @@ -40,13 +57,16 @@ export function createTestClient() { export function responseFromFixture(path: string): { status: number; + headers?: { [key: string]: string }; body?: string[]; } { const data = readFileSync(`${__dirname}/fixtures.http/${path}`, "utf-8"); const lines = data.split(/\r?\n/); const status = parseStatusCode(lines); - if (status === 204) return { status: 204 }; + const headers = parseHeaders(lines); + + if (status === 204) return { status: 204, headers }; - return { status: status, body: parseBody(lines) }; + return { status: status, headers, body: parseBody(lines) }; } diff --git a/test/vanity_name_servers.spec.ts b/test/vanity_name_servers.spec.ts index 1518492..cd408cc 100644 --- a/test/vanity_name_servers.spec.ts +++ b/test/vanity_name_servers.spec.ts @@ -42,7 +42,7 @@ describe("vanity name servers", () => { domainId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); }); }); diff --git a/test/webhooks.spec.ts b/test/webhooks.spec.ts index 42630a8..b9f00b6 100644 --- a/test/webhooks.spec.ts +++ b/test/webhooks.spec.ts @@ -139,7 +139,7 @@ describe("webhooks", () => { webhookId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); describe("when the webhook does not exist", () => { diff --git a/test/zone_records.spec.ts b/test/zone_records.spec.ts index 8410739..6746d84 100644 --- a/test/zone_records.spec.ts +++ b/test/zone_records.spec.ts @@ -293,7 +293,7 @@ describe("zone records", () => { recordId ); - expect(response).toEqual({}); + expect(response.rateLimit).toBeDefined(); }); describe("when the record does not exist", () => {