Skip to content
Merged
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 @@ -12,6 +12,10 @@ This project follows [Semantic Versioning](https://semver.org/).

- Dev-dependency security update: vitest and @vitest/coverage-v8 move to 4.1.12, which also brings @vitest/mocker to its patched release. The published package is unaffected; its only runtime dependency remains `commander`.

### Fixed

- **`rateLimitInfo.limitType` now says which limit a 429 hit.** It only read an `X-RateLimit-Type` header the API never sends, so it was always `null`. On a 429 it now reads the rate's name from the API's detail (`Rate limit exceeded for daily. Please try again in 16 hours, 58 minutes.`) and reports `"daily"` or `"burst"`, matching tango-python's `limit_type`; a per-endpoint premium daily budget reports `"daily"`. It is `null` when the detail names neither, and an `X-RateLimit-Type` header still wins if one is sent. `TangoRateLimitError` gains the same `limitType`, plus `detail` with the API's message.

## [1.4.0] - 2026-10-19

### Added
Expand Down
2 changes: 2 additions & 0 deletions docs/DEVELOPERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,8 @@ The resource key you passed doesn't exist in the API.

You've hit the API's rate limit. Back off and retry. The `timeoutMs` constructor option controls per-request timeout; rate limiting is an API-side concern.

`limitType` says which limit you hit: `"daily"` (resets at midnight UTC) or `"burst"` (clears within a minute), or `null` when the API didn't say. `detail` is the API's own message, such as `Rate limit exceeded for daily. Please try again in 16 hours, 58 minutes.` The same `limitType` lands on `client.rateLimitInfo`.

### Debugging HTTP

Pass a custom `fetchImpl` during development to log requests:
Expand Down
33 changes: 32 additions & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,41 @@ export class TangoEbuyAttachmentLinkError extends TangoValidationError {
}
}

// The API names the tripped rate in its 429 detail: "Rate limit exceeded for daily. Please try again in 16 hours, 58 minutes."
const RATE_NAME = /exceeded for ([\w-]+)/i;

/** Which limit a 429's detail names: `"daily"`, `"burst"`, or `null` when it names neither. */
function limitTypeFromDetail(detail: string): string | null {
// Read the rate's name, not the whole sentence: a daily lockout's wait reads "16 hours, 58 minutes", and "minute" there is not a burst.
const match = RATE_NAME.exec(detail);
const text = (match ? match[1] : detail).toLowerCase();
if (text.includes("daily") || /\bday\b/.test(text)) return "daily";
if (text.includes("burst") || text.includes("minute")) return "burst";
return null;
}

export class TangoRateLimitError extends TangoAPIError {
constructor(message = "Rate limit exceeded", statusCode?: number, responseData?: unknown) {
private readonly headerLimitType: string | null;

/** `limitType` is the server's `X-RateLimit-Type` header, which takes precedence over the body's detail when present. */
constructor(message = "Rate limit exceeded", statusCode?: number, responseData?: unknown, limitType?: string | null) {
super(message, statusCode, responseData);
this.name = "TangoRateLimitError";
this.headerLimitType = limitType || null;
}

/** Human-readable detail from the API response, when present. */
get detail(): string | null {
const data = this.responseData;
if (!isRecord(data)) return null;
return typeof data.detail === "string" ? data.detail : null;
}

/** Which limit was hit: `"daily"` or `"burst"`, from the `X-RateLimit-Type` header or else the response's detail; `null` when neither says. */
get limitType(): string | null {
if (this.headerLimitType) return this.headerLimitType;
const detail = this.detail;
return detail ? limitTypeFromDetail(detail) : null;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export interface RateLimitInfo {
resetIn: number | null;
/** Server's `Retry-After` header value, in seconds, when present. */
retryAfter: number | null;
/** Rate-limit tier label, if the server reports one (X-RateLimit-Type). */
/** Which limit a 429 hit (`"daily"` or `"burst"`), from the `X-RateLimit-Type` header or else the 429's detail; `null` on other responses unless the header is sent. */
limitType: string | null;
}

Expand Down
6 changes: 4 additions & 2 deletions src/utils/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,8 @@ export class HttpClient {
// Snapshot response metadata for observability (rate_limit_info /
// last_response_headers parity with the Python SDK).
this.lastResponseHeaders = headersToRecord(res.headers);
this.rateLimitInfo = parseRateLimit(res.headers);
const rateLimitInfo = parseRateLimit(res.headers);
this.rateLimitInfo = rateLimitInfo;

if (redirect === "manual" && res.status >= 300 && res.status < 400) {
const location = getHeader(res.headers, "location");
Expand Down Expand Up @@ -332,7 +333,8 @@ export class HttpClient {
}

if (res.status === 429) {
const e = new TangoRateLimitError("Rate limit exceeded", res.status, data);
const e = new TangoRateLimitError("Rate limit exceeded", res.status, data, rateLimitInfo.limitType);
rateLimitInfo.limitType = e.limitType;
(e as unknown as Record<string, unknown>).__retryable = true;
if (retryAfterMs !== null) {
(e as unknown as Record<string, unknown>).__retryAfterMs = retryAfterMs;
Expand Down
94 changes: 94 additions & 0 deletions tests/unit/rateLimitType.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { TangoClient, TangoRateLimitError } from "../../src/index.js";

function rateLimitedClient(body: string, headers: Record<string, string> = {}): TangoClient {
return new TangoClient({
apiKey: "k",
baseUrl: "http://localhost",
retries: 0,
fetchImpl: (async () => new Response(body, { status: 429, headers })) as unknown as typeof fetch,
});
}

async function catchRateLimit(client: TangoClient): Promise<TangoRateLimitError> {
try {
await client.getVersion();
} catch (err) {
if (err instanceof TangoRateLimitError) return err;
throw err;
}
throw new Error("expected a TangoRateLimitError");
}

describe("TangoRateLimitError.limitType", () => {
it.each([
["Rate limit exceeded for daily. Please try again in 16 hours, 58 minutes.", "daily"],
["Rate limit exceeded for daily. Please try again in 1 minute.", "daily"],
["Rate limit exceeded for burst. Please try again in 45 seconds.", "burst"],
["Rate limit exceeded for burst. Please try again in 1 minute.", "burst"],
["Rate limit exceeded for premium_daily. Please try again in 5 hours, 2 minutes.", "daily"],
["Too many requests.", null],
])("reads %j as %s", (detail, expected) => {
const error = new TangoRateLimitError("Rate limit exceeded", 429, { detail });

expect(error.limitType).toBe(expected);
});

it("is null without a detail", () => {
expect(new TangoRateLimitError("Rate limit exceeded", 429, null).limitType).toBeNull();
});
});

describe("rate limit type on a 429", () => {
it("reports a daily lockout as daily on both the error and rateLimitInfo", async () => {
const client = rateLimitedClient(
JSON.stringify({ detail: "Rate limit exceeded for daily. Please try again in 16 hours, 58 minutes.", wait_in_seconds: 61080 }),
{ "content-type": "application/json", "x-ratelimit-daily-remaining": "0" },
);

const error = await catchRateLimit(client);

expect(error.limitType).toBe("daily");
expect(error.detail).toBe("Rate limit exceeded for daily. Please try again in 16 hours, 58 minutes.");
expect(client.rateLimitInfo?.limitType).toBe("daily");
});

it("reports a burst limit as burst", async () => {
const client = rateLimitedClient(JSON.stringify({ detail: "Rate limit exceeded for burst. Please try again in 1 minute.", wait_in_seconds: 60 }));

const error = await catchRateLimit(client);

expect(error.limitType).toBe("burst");
expect(client.rateLimitInfo?.limitType).toBe("burst");
});

it("is null when the detail names no known limit", async () => {
const client = rateLimitedClient(JSON.stringify({ detail: "Too many requests.", wait_in_seconds: null }));

const error = await catchRateLimit(client);

expect(error.limitType).toBeNull();
expect(client.rateLimitInfo?.limitType).toBeNull();
});

it("is null when the body is not JSON", async () => {
const client = rateLimitedClient("<html>429 Too Many Requests</html>", { "content-type": "text/html" });

const error = await catchRateLimit(client);

expect(error.detail).toBeNull();
expect(error.limitType).toBeNull();
expect(client.rateLimitInfo?.limitType).toBeNull();
});

it("prefers an X-RateLimit-Type header over the detail", async () => {
const client = rateLimitedClient(JSON.stringify({ detail: "Rate limit exceeded for daily. Please try again in 2 hours." }), {
"x-ratelimit-type": "burst",
});

const error = await catchRateLimit(client);

expect(error.limitType).toBe("burst");
expect(client.rateLimitInfo?.limitType).toBe("burst");
});
});
Loading