@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 +8 -3
- package/dist/index.cjs +152 -32
- package/dist/index.d.cts +6 -4
- package/dist/index.d.ts +6 -4
- package/dist/index.js +152 -32
- package/package.json +1 -1
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
|
-
- **
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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.
|
|
218
|
+
this.rateLimitManager = new RateLimitManager({
|
|
219
|
+
apiRateLimitPerSecond: this.options.apiRateLimitPerSecond,
|
|
220
|
+
reportingRunsPerMinute: this.options.reportingRunsPerMinute,
|
|
98
221
|
maxConcurrent: this.options.maxConcurrent,
|
|
99
|
-
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return
|
|
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 (
|
|
321
|
+
if (!this.retryPolicy.shouldRetry(error, attempt, maxRetries)) {
|
|
196
322
|
throw error.response?.data || error;
|
|
197
323
|
}
|
|
198
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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.
|
|
159
|
+
this.rateLimitManager = new RateLimitManager({
|
|
160
|
+
apiRateLimitPerSecond: this.options.apiRateLimitPerSecond,
|
|
161
|
+
reportingRunsPerMinute: this.options.reportingRunsPerMinute,
|
|
39
162
|
maxConcurrent: this.options.maxConcurrent,
|
|
40
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return
|
|
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 (
|
|
262
|
+
if (!this.retryPolicy.shouldRetry(error, attempt, maxRetries)) {
|
|
137
263
|
throw error.response?.data || error;
|
|
138
264
|
}
|
|
139
|
-
|
|
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.
|
|
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",
|