diff --git a/CHANGELOG.md b/CHANGELOG.md index 8224e9e..68a2ecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/DEVELOPERS.md b/docs/DEVELOPERS.md index 5de21d7..8f88b46 100644 --- a/docs/DEVELOPERS.md +++ b/docs/DEVELOPERS.md @@ -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: diff --git a/src/errors.ts b/src/errors.ts index 7572981..305af8e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -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; } } diff --git a/src/types.ts b/src/types.ts index 3c928ec..d6ab20a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; } diff --git a/src/utils/http.ts b/src/utils/http.ts index 455e31c..95e5d37 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -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"); @@ -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).__retryable = true; if (retryAfterMs !== null) { (e as unknown as Record).__retryAfterMs = retryAfterMs; diff --git a/tests/unit/rateLimitType.test.ts b/tests/unit/rateLimitType.test.ts new file mode 100644 index 0000000..cf94910 --- /dev/null +++ b/tests/unit/rateLimitType.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { TangoClient, TangoRateLimitError } from "../../src/index.js"; + +function rateLimitedClient(body: string, headers: Record = {}): 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 { + 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("429 Too Many Requests", { "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"); + }); +});