Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions lib/fetcher/fetch-fetcher.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand Down
12 changes: 11 additions & 1 deletion lib/fetcher/fetcher.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -12,6 +21,7 @@ export type Fetcher = (params: {
}) => Promise<{
status: number;
body: string;
rateLimit: RateLimitHeaders;
}>;

let fetcherImports: {
Expand Down
24 changes: 22 additions & 2 deletions lib/fetcher/https-fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,34 @@
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;
url: string;
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 = {
Expand All @@ -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),
});
});
});
Expand Down
11 changes: 8 additions & 3 deletions lib/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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));
Expand Down
6 changes: 6 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
export type RateLimit = {
limit: number | null;
remaining: number | null;
reset: number | null;
};

export type Account = {
id: number;
email: string;
Expand Down
21 changes: 10 additions & 11 deletions test/accounts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,16 @@ describe("accounts", () => {

const result = await dnsimple.accounts.listAccounts();

expect(result).toEqual({
data: [
{
id: 123,
email: "[email protected]",
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: "[email protected]",
plan_identifier: "dnsimple-personal",
created_at: "2011-09-11T17:15:58Z",
updated_at: "2016-06-03T15:02:26Z",
},
]);
expect(result.rateLimit).toBeDefined();
});
});
});
50 changes: 49 additions & 1 deletion test/client.spec.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion test/contacts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ describe("contacts", () => {
contactId
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});

describe("when the contact does not exist", () => {
Expand Down
2 changes: 1 addition & 1 deletion test/domain_delegation_signer_records.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ describe("domains", () => {
dsRecordId
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});
});
});
2 changes: 1 addition & 1 deletion test/domain_dnssec.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe("domains", () => {
domainId
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});
});

Expand Down
2 changes: 1 addition & 1 deletion test/domain_email_forwards.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ describe("domains", () => {
emailForwardId
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});
});
});
4 changes: 2 additions & 2 deletions test/domain_pushes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe("domains", () => {
attributes
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});
});

Expand All @@ -117,7 +117,7 @@ describe("domains", () => {

const response = await dnsimple.domains.rejectPush(accountId, pushId);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});
});
});
4 changes: 2 additions & 2 deletions test/domain_services.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ describe("domain services", () => {
{}
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});
});

Expand All @@ -130,7 +130,7 @@ describe("domain services", () => {
serviceId
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});
});
});
2 changes: 1 addition & 1 deletion test/domains.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ describe("domains", () => {

const response = await dnsimple.domains.deleteDomain(accountId, domainId);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});
});
});
2 changes: 1 addition & 1 deletion test/registrar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ describe("registrar", () => {
domainId
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});
});
});
4 changes: 2 additions & 2 deletions test/registrar_auto_renewal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe("registrar auto renewal", () => {
domainId
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});

describe("when the domain does not exist", () => {
Expand Down Expand Up @@ -49,7 +49,7 @@ describe("registrar auto renewal", () => {
domainId
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});

describe("when the domain does not exist", () => {
Expand Down
2 changes: 1 addition & 1 deletion test/registrar_domain_delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ describe("domain delegation", () => {
domainId
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});
});
});
6 changes: 3 additions & 3 deletions test/templates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ describe("templates", () => {
templateId
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});
});

Expand All @@ -257,7 +257,7 @@ describe("templates", () => {
templateId
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});
});
});
Expand Down Expand Up @@ -483,7 +483,7 @@ describe("template records", () => {
recordId
);

expect(response).toEqual({});
expect(response.rateLimit).toBeDefined();
});
});
});
Loading