UNPKG

i18n-ai-translate

Version:

AI-powered localization CLI, Node library, and GitHub Action. Translate i18next JSON, Gettext PO, Java .properties, and iOS .strings with ChatGPT, Claude, Gemini, or local Ollama models.

165 lines 6.83 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const utils = __importStar(require("../utils")); const retry_1 = require("../retry"); const rate_limiter_1 = __importDefault(require("../rate_limiter")); describe("isRateLimitError", () => { it("matches HTTP status 429", () => { expect((0, retry_1.isRateLimitError)({ status: 429 })).toBe(true); }); it("matches Gemini RESOURCE_EXHAUSTED", () => { expect((0, retry_1.isRateLimitError)(new Error("RESOURCE_EXHAUSTED: quota exceeded"))).toBe(true); }); it("matches 'rate limit' in message", () => { expect((0, retry_1.isRateLimitError)(new Error("You've hit the rate limit"))).toBe(true); }); it("rejects unrelated errors", () => { expect((0, retry_1.isRateLimitError)(new Error("oops"))).toBe(false); expect((0, retry_1.isRateLimitError)({ status: 500 })).toBe(false); expect((0, retry_1.isRateLimitError)(null)).toBe(false); expect((0, retry_1.isRateLimitError)(undefined)).toBe(false); }); }); describe("extractRetryAfterMs", () => { it("parses Retry-After seconds", () => { expect((0, retry_1.extractRetryAfterMs)({ headers: { "retry-after": "3" } })).toBe(3_000); }); it("parses HTTP-date Retry-After", () => { const future = new Date(Date.now() + 5_000).toUTCString(); const ms = (0, retry_1.extractRetryAfterMs)({ headers: { "retry-after": future }, }); expect(ms).not.toBeNull(); expect(ms).toBeGreaterThan(0); expect(ms).toBeLessThanOrEqual(5_000); }); it("returns null when no header", () => { expect((0, retry_1.extractRetryAfterMs)({})).toBeNull(); expect((0, retry_1.extractRetryAfterMs)(new Error("no headers"))).toBeNull(); }); }); describe("retryWithBackoff", () => { const mockedDelay = utils.delay; afterEach(() => { jest.clearAllMocks(); }); it("returns the job's value on first success", async () => { const job = jest.fn(() => Promise.resolve("ok")); const result = await (0, retry_1.retryWithBackoff)(job, { maxRetries: 3 }); expect(result).toBe("ok"); expect(job).toHaveBeenCalledTimes(1); expect(mockedDelay).not.toHaveBeenCalled(); }); it("retries transient failures up to maxRetries", async () => { const job = jest .fn() .mockRejectedValueOnce(new Error("transient")) .mockRejectedValueOnce(new Error("transient")) .mockResolvedValueOnce("ok"); const result = await (0, retry_1.retryWithBackoff)(job, { baseDelayMs: 10, maxDelayMs: 1000, maxRetries: 3, }); expect(result).toBe("ok"); expect(job).toHaveBeenCalledTimes(3); expect(mockedDelay).toHaveBeenCalledTimes(2); }); it("throws after exhausting retries", async () => { const job = jest.fn(() => Promise.reject(new Error("nope"))); await expect((0, retry_1.retryWithBackoff)(job, { baseDelayMs: 1, maxRetries: 2 })).rejects.toThrow("nope"); expect(job).toHaveBeenCalledTimes(3); // initial + 2 retries }); it("honors Retry-After on 429 responses", async () => { const err = Object.assign(new Error("too many"), { headers: { "retry-after": "2" }, status: 429, }); const job = jest .fn() .mockRejectedValueOnce(err) .mockResolvedValueOnce("ok"); await (0, retry_1.retryWithBackoff)(job, { maxDelayMs: 60_000, maxRetries: 3 }); expect(mockedDelay).toHaveBeenCalledWith(2_000); }); it("caps Retry-After at maxDelayMs", async () => { const err = Object.assign(new Error("too many"), { headers: { "retry-after": "3600" }, status: 429, }); const job = jest .fn() .mockRejectedValueOnce(err) .mockResolvedValueOnce("ok"); await (0, retry_1.retryWithBackoff)(job, { maxDelayMs: 5_000, maxRetries: 3 }); expect(mockedDelay).toHaveBeenCalledWith(5_000); }); it("penalizes the shared rate limiter on 429", async () => { const rl = new rate_limiter_1.default(100, false); const penalize = jest.spyOn(rl, "penalize"); const err = Object.assign(new Error("429"), { headers: { "retry-after": "1" }, status: 429, }); const job = jest .fn() .mockRejectedValueOnce(err) .mockResolvedValueOnce("ok"); await (0, retry_1.retryWithBackoff)(job, { maxRetries: 3, rateLimiter: rl, }); expect(penalize).toHaveBeenCalledWith(1_000); }); it("does not penalize on non-rate-limit errors", async () => { const rl = new rate_limiter_1.default(100, false); const penalize = jest.spyOn(rl, "penalize"); const job = jest .fn() .mockRejectedValueOnce(new Error("500")) .mockResolvedValueOnce("ok"); await (0, retry_1.retryWithBackoff)(job, { baseDelayMs: 1, maxRetries: 3, rateLimiter: rl, }); expect(penalize).not.toHaveBeenCalled(); }); }); //# sourceMappingURL=retry.spec.js.map