@masonlandcattle/servicetitan-sdk 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ A pragmatic ServiceTitan API client with:
5
5
  - Robust auth (Client Credentials)
6
6
  - **Retries** with exponential backoff + jitter
7
7
  - Honors **Retry-After**, handles 429/5xx
8
- - **Rate limiting** with Bottleneck (concurrency + minTime)
8
+ - **Built-in rate limiting** with queued waiting in the client transport
9
9
  - URL builder and **pagination** helpers (`getAll`)
10
10
  - Broad tenant API namespace coverage across the current ServiceTitan developer portal
11
11
  - Exported TypeScript helper types for better IntelliSense when using the SDK
@@ -70,8 +70,8 @@ const st = createClient({
70
70
  clientSecret: process.env.SECRET_KEY!,
71
71
  environment: (process.env.ENVIRONMENT as any) || "production",
72
72
  retries: 3,
73
- maxConcurrent: 5,
74
- minTimeMs: 100,
73
+ apiRateLimitPerSecond: 45,
74
+ maxConcurrent: 20,
75
75
  });
76
76
 
77
77
  // List jobs (single page)
@@ -91,6 +91,11 @@ const materials = await st.pricebook.listMaterials({}, { all: true, pageSize: 50
91
91
 
92
92
  - Local OpenAPI JSON files in [`ServiceTitanOpenAPIJson`](./ServiceTitanOpenAPIJson) are the source of truth for this SDK.
93
93
  - Breaking changes are allowed when the documented ServiceTitan contract and the previous SDK surface disagree.
94
+ - Requests are throttled inside [`src/client.ts`](./src/client.ts), so callers do not need to add their own queueing to use helpers like `client.request(...)` or `client.getAll(...)`.
95
+ - Normal tenant API traffic is queued at `45` requests per second per tenant by default. You can lower it or raise it during client creation, but the SDK clamps `apiRateLimitPerSecond` to `55` max.
96
+ - Reporting runs made through `st.reporting.getReportData(...)` are additionally queued at `5` runs per minute for the same `tenant + report category + report id`.
97
+ - When a limit is exceeded, the SDK waits in an in-memory queue instead of failing immediately. This means large `getAll()` calls or bursts of individual requests may take longer to complete under load.
98
+ - If ServiceTitan still responds with `429`, the client honors `Retry-After` when present and retries with exponential backoff as a fallback.
94
99
  - Export feeds generally use `{ from, includeRecentChanges }` continuation params and return `{ data, hasMore, continueFrom }`.
95
100
  - Binary telecom media endpoints return `ArrayBuffer`.
96
101
  - `options.all` is only used on endpoints with safe paginated `data` plus `hasMore` behavior.
package/dist/index.cjs CHANGED
@@ -65,8 +65,125 @@ module.exports = __toCommonJS(index_exports);
65
65
 
66
66
  // src/client.ts
67
67
  var import_axios = __toESM(require("axios"), 1);
68
- var import_bottleneck = __toESM(require("bottleneck"), 1);
69
68
  var import_qs = __toESM(require("qs"), 1);
69
+
70
+ // src/transport/request-context.ts
71
+ function createRequestContext(options) {
72
+ const { method, endpoint, defaultTenantId } = options;
73
+ const tenantMatch = endpoint.match(/\/tenant\/([^/]+)/i);
74
+ const categoryMatch = endpoint.match(/^\/([^/]+)\/v\d+\//i);
75
+ const reportMatch = method === "post" ? endpoint.match(/\/reporting\/v\d+\/tenant\/([^/]+)\/report-category\/([^/]+)\/reports\/([^/]+)\/data$/i) : null;
76
+ return {
77
+ method,
78
+ endpoint,
79
+ tenantId: tenantMatch?.[1] || defaultTenantId,
80
+ category: categoryMatch?.[1],
81
+ reportCategory: reportMatch?.[2],
82
+ reportId: reportMatch?.[3],
83
+ isReportDataRequest: Boolean(reportMatch)
84
+ };
85
+ }
86
+
87
+ // src/transport/rate-limit-manager.ts
88
+ var import_bottleneck = __toESM(require("bottleneck"), 1);
89
+ var RateLimitManager = class {
90
+ constructor(options) {
91
+ this.options = options;
92
+ this.tenantLimiters = /* @__PURE__ */ new Map();
93
+ this.reportLimiters = /* @__PURE__ */ new Map();
94
+ }
95
+ createLimiter(options) {
96
+ return new import_bottleneck.default({
97
+ maxConcurrent: options.maxConcurrent,
98
+ minTime: options.minTime,
99
+ reservoir: options.reservoir,
100
+ reservoirRefreshAmount: options.reservoirRefreshAmount,
101
+ reservoirRefreshInterval: options.reservoirRefreshInterval
102
+ });
103
+ }
104
+ getTenantLimiter(tenantId) {
105
+ const tenantKey = `tenant:${tenantId}`;
106
+ let limiter = this.tenantLimiters.get(tenantKey);
107
+ if (!limiter) {
108
+ limiter = this.createLimiter({
109
+ maxConcurrent: this.options.maxConcurrent,
110
+ minTime: this.options.minTimeMs,
111
+ reservoir: this.options.apiRateLimitPerSecond,
112
+ reservoirRefreshAmount: this.options.apiRateLimitPerSecond,
113
+ reservoirRefreshInterval: 1e3
114
+ });
115
+ this.tenantLimiters.set(tenantKey, limiter);
116
+ }
117
+ return limiter;
118
+ }
119
+ getReportLimiter(context) {
120
+ if (!context.isReportDataRequest || !context.reportCategory || !context.reportId) {
121
+ return null;
122
+ }
123
+ const reportKey = `report:${context.tenantId}:${context.reportCategory}:${context.reportId}`;
124
+ let limiter = this.reportLimiters.get(reportKey);
125
+ if (!limiter) {
126
+ limiter = this.createLimiter({
127
+ maxConcurrent: this.options.maxConcurrent,
128
+ minTime: this.options.minTimeMs,
129
+ reservoir: this.options.reportingRunsPerMinute,
130
+ reservoirRefreshAmount: this.options.reportingRunsPerMinute,
131
+ reservoirRefreshInterval: 6e4
132
+ });
133
+ this.reportLimiters.set(reportKey, limiter);
134
+ }
135
+ return limiter;
136
+ }
137
+ async schedule(context, fn) {
138
+ const tenantLimiter = this.getTenantLimiter(context.tenantId);
139
+ const reportLimiter = this.getReportLimiter(context);
140
+ if (!reportLimiter) {
141
+ return tenantLimiter.schedule(fn);
142
+ }
143
+ return reportLimiter.schedule(() => tenantLimiter.schedule(fn));
144
+ }
145
+ };
146
+
147
+ // src/transport/retry-policy.ts
148
+ var RetryPolicy = class {
149
+ constructor(options) {
150
+ this.options = options;
151
+ }
152
+ async delay(ms) {
153
+ await new Promise((resolve) => setTimeout(resolve, ms));
154
+ }
155
+ computeBackoff(attempt) {
156
+ const exp = this.options.retryInitialDelayMs * Math.pow(2, attempt);
157
+ const jitter = Math.random() * this.options.retryInitialDelayMs;
158
+ return Math.min(3e4, exp + jitter);
159
+ }
160
+ isRetryable(error) {
161
+ const status = error?.response?.status;
162
+ if (!status) return true;
163
+ if (status === 429) return true;
164
+ return status >= 500;
165
+ }
166
+ parseRetryAfterMs(retryAfter) {
167
+ if (!retryAfter) return null;
168
+ const seconds = Number(retryAfter);
169
+ if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1e3);
170
+ const retryAt = Date.parse(retryAfter);
171
+ if (Number.isNaN(retryAt)) return null;
172
+ return Math.max(0, retryAt - Date.now());
173
+ }
174
+ async waitBeforeRetry(attempt, retryAfter) {
175
+ let delayMs = this.computeBackoff(attempt);
176
+ const retryAfterMs = this.parseRetryAfterMs(retryAfter);
177
+ if (retryAfterMs != null) delayMs = Math.max(delayMs, retryAfterMs);
178
+ await this.delay(delayMs);
179
+ }
180
+ shouldRetry(error, attempt, retriesOverride) {
181
+ const maxRetries = retriesOverride ?? this.options.retries;
182
+ return attempt < maxRetries && this.isRetryable(error);
183
+ }
184
+ };
185
+
186
+ // src/client.ts
70
187
  var ServiceTitanClient = class {
71
188
  constructor(opts = {}) {
72
189
  this.token = null;
@@ -75,6 +192,8 @@ var ServiceTitanClient = class {
75
192
  const env = opts.environment || process.env.ENVIRONMENT || "production";
76
193
  const authUrl = opts.authUrl || (env === "production" ? "https://auth.servicetitan.io/connect/token" : "https://auth-integration.servicetitan.io/connect/token");
77
194
  const apiBaseUrl = opts.apiBaseUrl || (env === "production" ? "https://api.servicetitan.io" : "https://api-integration.servicetitan.io");
195
+ const apiRateLimitPerSecond = this.normalizeApiRateLimit(opts.apiRateLimitPerSecond);
196
+ const reportingRunsPerMinute = this.normalizePositiveRateLimit(opts.reportingRunsPerMinute, 5);
78
197
  const defaults = {
79
198
  tenantId: opts.tenantId || process.env.TENANT_ID || "",
80
199
  appKey: opts.appKey || process.env.APP_KEY || "",
@@ -86,17 +205,25 @@ var ServiceTitanClient = class {
86
205
  timeoutMs: opts.timeoutMs ?? 3e4,
87
206
  retries: opts.retries ?? 3,
88
207
  retryInitialDelayMs: opts.retryInitialDelayMs ?? 1e3,
89
- maxConcurrent: opts.maxConcurrent ?? 5,
90
- minTimeMs: opts.minTimeMs ?? 100,
208
+ apiRateLimitPerSecond,
209
+ reportingRunsPerMinute,
210
+ maxConcurrent: opts.maxConcurrent ?? 20,
211
+ minTimeMs: opts.minTimeMs ?? 0,
91
212
  logger: opts.logger || console
92
213
  };
93
214
  if (!defaults.tenantId || !defaults.appKey || !defaults.clientId || !defaults.clientSecret) {
94
215
  throw new Error("Missing required configuration: TENANT_ID, APP_KEY, CLIENT_ID, SECRET_KEY");
95
216
  }
96
217
  this.options = defaults;
97
- this.limiter = new import_bottleneck.default({
218
+ this.rateLimitManager = new RateLimitManager({
219
+ apiRateLimitPerSecond: this.options.apiRateLimitPerSecond,
220
+ reportingRunsPerMinute: this.options.reportingRunsPerMinute,
98
221
  maxConcurrent: this.options.maxConcurrent,
99
- minTime: this.options.minTimeMs
222
+ minTimeMs: this.options.minTimeMs
223
+ });
224
+ this.retryPolicy = new RetryPolicy({
225
+ retries: this.options.retries,
226
+ retryInitialDelayMs: this.options.retryInitialDelayMs
100
227
  });
101
228
  this.axiosInstance = import_axios.default.create({
102
229
  baseURL: this.options.apiBaseUrl,
@@ -127,6 +254,16 @@ var ServiceTitanClient = class {
127
254
  }
128
255
  );
129
256
  }
257
+ normalizeApiRateLimit(value) {
258
+ if (value == null) return 45;
259
+ if (!Number.isFinite(value)) return 45;
260
+ return Math.max(1, Math.min(55, Math.floor(value)));
261
+ }
262
+ normalizePositiveRateLimit(value, fallback) {
263
+ if (value == null) return fallback;
264
+ if (!Number.isFinite(value)) return fallback;
265
+ return Math.max(1, Math.floor(value));
266
+ }
130
267
  async refreshToken() {
131
268
  if (this.refreshing) return this.refreshing;
132
269
  this.refreshing = (async () => {
@@ -159,24 +296,13 @@ var ServiceTitanClient = class {
159
296
  throw err;
160
297
  }
161
298
  }
162
- async delay(ms) {
163
- return new Promise((resolve) => setTimeout(resolve, ms));
164
- }
165
- computeBackoff(base, attempt) {
166
- const exp = base * Math.pow(2, attempt);
167
- const jitter = Math.random() * base;
168
- return Math.min(3e4, exp + jitter);
169
- }
170
- isRetryable(error) {
171
- if (!error) return true;
172
- const status = error.response?.status;
173
- if (!status) return true;
174
- if (status === 429) return true;
175
- if (status >= 500) return true;
176
- return false;
177
- }
178
- async schedule(fn) {
179
- return this.limiter.schedule(fn);
299
+ async schedule(method, endpoint, fn) {
300
+ const context = createRequestContext({
301
+ method,
302
+ endpoint,
303
+ defaultTenantId: this.options.tenantId
304
+ });
305
+ return this.rateLimitManager.schedule(context, fn);
180
306
  }
181
307
  async request(method, endpoint, options = {}) {
182
308
  await this.authenticate();
@@ -185,23 +311,17 @@ var ServiceTitanClient = class {
185
311
  let attempt = 0;
186
312
  while (true) {
187
313
  try {
188
- const result = await this.schedule(async () => {
314
+ const result = await this.schedule(method, endpoint, async () => {
189
315
  const resp = await this.axiosInstance.request({ method, url: endpoint, params, data, headers, responseType });
190
316
  return resp.data;
191
317
  });
192
318
  return result;
193
319
  } catch (error) {
194
320
  this.options.logger.warn?.("ServiceTitan API error", error.response?.status, error.response?.data || error.message);
195
- if (attempt >= maxRetries || !this.isRetryable(error)) {
321
+ if (!this.retryPolicy.shouldRetry(error, attempt, maxRetries)) {
196
322
  throw error.response?.data || error;
197
323
  }
198
- let delayMs = this.computeBackoff(this.options.retryInitialDelayMs, attempt);
199
- const retryAfter = error.response?.headers?.["retry-after"];
200
- if (retryAfter) {
201
- const parsed = Number(retryAfter);
202
- if (!Number.isNaN(parsed)) delayMs = Math.max(delayMs, parsed * 1e3);
203
- }
204
- await this.delay(delayMs);
324
+ await this.retryPolicy.waitBeforeRetry(attempt, error.response?.headers?.["retry-after"]);
205
325
  attempt += 1;
206
326
  }
207
327
  }
package/dist/index.d.cts CHANGED
@@ -18,6 +18,8 @@ interface ServiceTitanClientOptions {
18
18
  timeoutMs?: number;
19
19
  retries?: number;
20
20
  retryInitialDelayMs?: number;
21
+ apiRateLimitPerSecond?: number;
22
+ reportingRunsPerMinute?: number;
21
23
  maxConcurrent?: number;
22
24
  minTimeMs?: number;
23
25
  logger?: Logger;
@@ -30,17 +32,17 @@ type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
30
32
 
31
33
  declare class ServiceTitanClient {
32
34
  private axiosInstance;
33
- private limiter;
34
35
  private options;
36
+ private rateLimitManager;
37
+ private retryPolicy;
35
38
  private token;
36
39
  private tokenExpiry;
37
40
  private refreshing;
38
41
  constructor(opts?: ServiceTitanClientOptions);
42
+ private normalizeApiRateLimit;
43
+ private normalizePositiveRateLimit;
39
44
  private refreshToken;
40
45
  authenticate(): Promise<string>;
41
- private delay;
42
- private computeBackoff;
43
- private isRetryable;
44
46
  private schedule;
45
47
  request<T = any>(method: HttpMethod, endpoint: string, options?: {
46
48
  params?: Record<string, any>;
package/dist/index.d.ts CHANGED
@@ -18,6 +18,8 @@ interface ServiceTitanClientOptions {
18
18
  timeoutMs?: number;
19
19
  retries?: number;
20
20
  retryInitialDelayMs?: number;
21
+ apiRateLimitPerSecond?: number;
22
+ reportingRunsPerMinute?: number;
21
23
  maxConcurrent?: number;
22
24
  minTimeMs?: number;
23
25
  logger?: Logger;
@@ -30,17 +32,17 @@ type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
30
32
 
31
33
  declare class ServiceTitanClient {
32
34
  private axiosInstance;
33
- private limiter;
34
35
  private options;
36
+ private rateLimitManager;
37
+ private retryPolicy;
35
38
  private token;
36
39
  private tokenExpiry;
37
40
  private refreshing;
38
41
  constructor(opts?: ServiceTitanClientOptions);
42
+ private normalizeApiRateLimit;
43
+ private normalizePositiveRateLimit;
39
44
  private refreshToken;
40
45
  authenticate(): Promise<string>;
41
- private delay;
42
- private computeBackoff;
43
- private isRetryable;
44
46
  private schedule;
45
47
  request<T = any>(method: HttpMethod, endpoint: string, options?: {
46
48
  params?: Record<string, any>;
package/dist/index.js CHANGED
@@ -6,8 +6,125 @@ var __export = (target, all) => {
6
6
 
7
7
  // src/client.ts
8
8
  import axios from "axios";
9
- import Bottleneck from "bottleneck";
10
9
  import qs from "qs";
10
+
11
+ // src/transport/request-context.ts
12
+ function createRequestContext(options) {
13
+ const { method, endpoint, defaultTenantId } = options;
14
+ const tenantMatch = endpoint.match(/\/tenant\/([^/]+)/i);
15
+ const categoryMatch = endpoint.match(/^\/([^/]+)\/v\d+\//i);
16
+ const reportMatch = method === "post" ? endpoint.match(/\/reporting\/v\d+\/tenant\/([^/]+)\/report-category\/([^/]+)\/reports\/([^/]+)\/data$/i) : null;
17
+ return {
18
+ method,
19
+ endpoint,
20
+ tenantId: tenantMatch?.[1] || defaultTenantId,
21
+ category: categoryMatch?.[1],
22
+ reportCategory: reportMatch?.[2],
23
+ reportId: reportMatch?.[3],
24
+ isReportDataRequest: Boolean(reportMatch)
25
+ };
26
+ }
27
+
28
+ // src/transport/rate-limit-manager.ts
29
+ import Bottleneck from "bottleneck";
30
+ var RateLimitManager = class {
31
+ constructor(options) {
32
+ this.options = options;
33
+ this.tenantLimiters = /* @__PURE__ */ new Map();
34
+ this.reportLimiters = /* @__PURE__ */ new Map();
35
+ }
36
+ createLimiter(options) {
37
+ return new Bottleneck({
38
+ maxConcurrent: options.maxConcurrent,
39
+ minTime: options.minTime,
40
+ reservoir: options.reservoir,
41
+ reservoirRefreshAmount: options.reservoirRefreshAmount,
42
+ reservoirRefreshInterval: options.reservoirRefreshInterval
43
+ });
44
+ }
45
+ getTenantLimiter(tenantId) {
46
+ const tenantKey = `tenant:${tenantId}`;
47
+ let limiter = this.tenantLimiters.get(tenantKey);
48
+ if (!limiter) {
49
+ limiter = this.createLimiter({
50
+ maxConcurrent: this.options.maxConcurrent,
51
+ minTime: this.options.minTimeMs,
52
+ reservoir: this.options.apiRateLimitPerSecond,
53
+ reservoirRefreshAmount: this.options.apiRateLimitPerSecond,
54
+ reservoirRefreshInterval: 1e3
55
+ });
56
+ this.tenantLimiters.set(tenantKey, limiter);
57
+ }
58
+ return limiter;
59
+ }
60
+ getReportLimiter(context) {
61
+ if (!context.isReportDataRequest || !context.reportCategory || !context.reportId) {
62
+ return null;
63
+ }
64
+ const reportKey = `report:${context.tenantId}:${context.reportCategory}:${context.reportId}`;
65
+ let limiter = this.reportLimiters.get(reportKey);
66
+ if (!limiter) {
67
+ limiter = this.createLimiter({
68
+ maxConcurrent: this.options.maxConcurrent,
69
+ minTime: this.options.minTimeMs,
70
+ reservoir: this.options.reportingRunsPerMinute,
71
+ reservoirRefreshAmount: this.options.reportingRunsPerMinute,
72
+ reservoirRefreshInterval: 6e4
73
+ });
74
+ this.reportLimiters.set(reportKey, limiter);
75
+ }
76
+ return limiter;
77
+ }
78
+ async schedule(context, fn) {
79
+ const tenantLimiter = this.getTenantLimiter(context.tenantId);
80
+ const reportLimiter = this.getReportLimiter(context);
81
+ if (!reportLimiter) {
82
+ return tenantLimiter.schedule(fn);
83
+ }
84
+ return reportLimiter.schedule(() => tenantLimiter.schedule(fn));
85
+ }
86
+ };
87
+
88
+ // src/transport/retry-policy.ts
89
+ var RetryPolicy = class {
90
+ constructor(options) {
91
+ this.options = options;
92
+ }
93
+ async delay(ms) {
94
+ await new Promise((resolve) => setTimeout(resolve, ms));
95
+ }
96
+ computeBackoff(attempt) {
97
+ const exp = this.options.retryInitialDelayMs * Math.pow(2, attempt);
98
+ const jitter = Math.random() * this.options.retryInitialDelayMs;
99
+ return Math.min(3e4, exp + jitter);
100
+ }
101
+ isRetryable(error) {
102
+ const status = error?.response?.status;
103
+ if (!status) return true;
104
+ if (status === 429) return true;
105
+ return status >= 500;
106
+ }
107
+ parseRetryAfterMs(retryAfter) {
108
+ if (!retryAfter) return null;
109
+ const seconds = Number(retryAfter);
110
+ if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1e3);
111
+ const retryAt = Date.parse(retryAfter);
112
+ if (Number.isNaN(retryAt)) return null;
113
+ return Math.max(0, retryAt - Date.now());
114
+ }
115
+ async waitBeforeRetry(attempt, retryAfter) {
116
+ let delayMs = this.computeBackoff(attempt);
117
+ const retryAfterMs = this.parseRetryAfterMs(retryAfter);
118
+ if (retryAfterMs != null) delayMs = Math.max(delayMs, retryAfterMs);
119
+ await this.delay(delayMs);
120
+ }
121
+ shouldRetry(error, attempt, retriesOverride) {
122
+ const maxRetries = retriesOverride ?? this.options.retries;
123
+ return attempt < maxRetries && this.isRetryable(error);
124
+ }
125
+ };
126
+
127
+ // src/client.ts
11
128
  var ServiceTitanClient = class {
12
129
  constructor(opts = {}) {
13
130
  this.token = null;
@@ -16,6 +133,8 @@ var ServiceTitanClient = class {
16
133
  const env = opts.environment || process.env.ENVIRONMENT || "production";
17
134
  const authUrl = opts.authUrl || (env === "production" ? "https://auth.servicetitan.io/connect/token" : "https://auth-integration.servicetitan.io/connect/token");
18
135
  const apiBaseUrl = opts.apiBaseUrl || (env === "production" ? "https://api.servicetitan.io" : "https://api-integration.servicetitan.io");
136
+ const apiRateLimitPerSecond = this.normalizeApiRateLimit(opts.apiRateLimitPerSecond);
137
+ const reportingRunsPerMinute = this.normalizePositiveRateLimit(opts.reportingRunsPerMinute, 5);
19
138
  const defaults = {
20
139
  tenantId: opts.tenantId || process.env.TENANT_ID || "",
21
140
  appKey: opts.appKey || process.env.APP_KEY || "",
@@ -27,17 +146,25 @@ var ServiceTitanClient = class {
27
146
  timeoutMs: opts.timeoutMs ?? 3e4,
28
147
  retries: opts.retries ?? 3,
29
148
  retryInitialDelayMs: opts.retryInitialDelayMs ?? 1e3,
30
- maxConcurrent: opts.maxConcurrent ?? 5,
31
- minTimeMs: opts.minTimeMs ?? 100,
149
+ apiRateLimitPerSecond,
150
+ reportingRunsPerMinute,
151
+ maxConcurrent: opts.maxConcurrent ?? 20,
152
+ minTimeMs: opts.minTimeMs ?? 0,
32
153
  logger: opts.logger || console
33
154
  };
34
155
  if (!defaults.tenantId || !defaults.appKey || !defaults.clientId || !defaults.clientSecret) {
35
156
  throw new Error("Missing required configuration: TENANT_ID, APP_KEY, CLIENT_ID, SECRET_KEY");
36
157
  }
37
158
  this.options = defaults;
38
- this.limiter = new Bottleneck({
159
+ this.rateLimitManager = new RateLimitManager({
160
+ apiRateLimitPerSecond: this.options.apiRateLimitPerSecond,
161
+ reportingRunsPerMinute: this.options.reportingRunsPerMinute,
39
162
  maxConcurrent: this.options.maxConcurrent,
40
- minTime: this.options.minTimeMs
163
+ minTimeMs: this.options.minTimeMs
164
+ });
165
+ this.retryPolicy = new RetryPolicy({
166
+ retries: this.options.retries,
167
+ retryInitialDelayMs: this.options.retryInitialDelayMs
41
168
  });
42
169
  this.axiosInstance = axios.create({
43
170
  baseURL: this.options.apiBaseUrl,
@@ -68,6 +195,16 @@ var ServiceTitanClient = class {
68
195
  }
69
196
  );
70
197
  }
198
+ normalizeApiRateLimit(value) {
199
+ if (value == null) return 45;
200
+ if (!Number.isFinite(value)) return 45;
201
+ return Math.max(1, Math.min(55, Math.floor(value)));
202
+ }
203
+ normalizePositiveRateLimit(value, fallback) {
204
+ if (value == null) return fallback;
205
+ if (!Number.isFinite(value)) return fallback;
206
+ return Math.max(1, Math.floor(value));
207
+ }
71
208
  async refreshToken() {
72
209
  if (this.refreshing) return this.refreshing;
73
210
  this.refreshing = (async () => {
@@ -100,24 +237,13 @@ var ServiceTitanClient = class {
100
237
  throw err;
101
238
  }
102
239
  }
103
- async delay(ms) {
104
- return new Promise((resolve) => setTimeout(resolve, ms));
105
- }
106
- computeBackoff(base, attempt) {
107
- const exp = base * Math.pow(2, attempt);
108
- const jitter = Math.random() * base;
109
- return Math.min(3e4, exp + jitter);
110
- }
111
- isRetryable(error) {
112
- if (!error) return true;
113
- const status = error.response?.status;
114
- if (!status) return true;
115
- if (status === 429) return true;
116
- if (status >= 500) return true;
117
- return false;
118
- }
119
- async schedule(fn) {
120
- return this.limiter.schedule(fn);
240
+ async schedule(method, endpoint, fn) {
241
+ const context = createRequestContext({
242
+ method,
243
+ endpoint,
244
+ defaultTenantId: this.options.tenantId
245
+ });
246
+ return this.rateLimitManager.schedule(context, fn);
121
247
  }
122
248
  async request(method, endpoint, options = {}) {
123
249
  await this.authenticate();
@@ -126,23 +252,17 @@ var ServiceTitanClient = class {
126
252
  let attempt = 0;
127
253
  while (true) {
128
254
  try {
129
- const result = await this.schedule(async () => {
255
+ const result = await this.schedule(method, endpoint, async () => {
130
256
  const resp = await this.axiosInstance.request({ method, url: endpoint, params, data, headers, responseType });
131
257
  return resp.data;
132
258
  });
133
259
  return result;
134
260
  } catch (error) {
135
261
  this.options.logger.warn?.("ServiceTitan API error", error.response?.status, error.response?.data || error.message);
136
- if (attempt >= maxRetries || !this.isRetryable(error)) {
262
+ if (!this.retryPolicy.shouldRetry(error, attempt, maxRetries)) {
137
263
  throw error.response?.data || error;
138
264
  }
139
- let delayMs = this.computeBackoff(this.options.retryInitialDelayMs, attempt);
140
- const retryAfter = error.response?.headers?.["retry-after"];
141
- if (retryAfter) {
142
- const parsed = Number(retryAfter);
143
- if (!Number.isNaN(parsed)) delayMs = Math.max(delayMs, parsed * 1e3);
144
- }
145
- await this.delay(delayMs);
265
+ await this.retryPolicy.waitBeforeRetry(attempt, error.response?.headers?.["retry-after"]);
146
266
  attempt += 1;
147
267
  }
148
268
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@masonlandcattle/servicetitan-sdk",
3
3
  "author": "Mason Land & Cattle (https://github.com/MasonLandCattle)",
4
- "version": "0.9.0",
4
+ "version": "0.9.1",
5
5
  "description": "ServiceTitan API SDK for Node.js and TypeScript with retries, rate limiting, pagination, and broad tenant API coverage.",
6
6
  "keywords": [
7
7
  "servicetitan",