@gpc-cli/api 1.0.19 → 1.0.21
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 +34 -28
- package/dist/index.d.ts +30 -4
- package/dist/index.js +416 -127
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// src/errors.ts
|
|
2
|
-
var
|
|
2
|
+
var PlayApiError = class extends Error {
|
|
3
3
|
constructor(message, code, statusCode, suggestion) {
|
|
4
4
|
super(message);
|
|
5
5
|
this.code = code;
|
|
6
6
|
this.statusCode = statusCode;
|
|
7
7
|
this.suggestion = suggestion;
|
|
8
|
-
this.name = "
|
|
8
|
+
this.name = "PlayApiError";
|
|
9
9
|
}
|
|
10
10
|
exitCode = 4;
|
|
11
11
|
toJSON() {
|
|
@@ -21,8 +21,269 @@ var ApiError = class extends Error {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
// src/http.ts
|
|
24
|
-
import { readFile } from "fs/promises";
|
|
24
|
+
import { readFile, stat as stat2 } from "fs/promises";
|
|
25
25
|
import { resolve, isAbsolute } from "path";
|
|
26
|
+
|
|
27
|
+
// src/resumable-upload.ts
|
|
28
|
+
import { open, stat } from "fs/promises";
|
|
29
|
+
var CHUNK_ALIGNMENT = 256 * 1024;
|
|
30
|
+
var DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024;
|
|
31
|
+
var RESUMABLE_THRESHOLD = 5 * 1024 * 1024;
|
|
32
|
+
function envInt(name) {
|
|
33
|
+
const val = process.env[name];
|
|
34
|
+
if (val === void 0) return void 0;
|
|
35
|
+
const n = Number(val);
|
|
36
|
+
return Number.isFinite(n) ? n : void 0;
|
|
37
|
+
}
|
|
38
|
+
function resolveChunkSize(explicit) {
|
|
39
|
+
const size = explicit ?? envInt("GPC_UPLOAD_CHUNK_SIZE") ?? DEFAULT_CHUNK_SIZE;
|
|
40
|
+
if (size < CHUNK_ALIGNMENT || size % CHUNK_ALIGNMENT !== 0) {
|
|
41
|
+
throw new PlayApiError(
|
|
42
|
+
`Chunk size must be a multiple of 256 KB (got ${size} bytes)`,
|
|
43
|
+
"UPLOAD_INVALID_CHUNK_SIZE",
|
|
44
|
+
void 0,
|
|
45
|
+
`Use a multiple of 262144 (256 KB). Common values: 1048576 (1 MB), 8388608 (8 MB), 16777216 (16 MB).`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return size;
|
|
49
|
+
}
|
|
50
|
+
function jitteredDelay(base, attempt, max) {
|
|
51
|
+
const exponential = base * 2 ** attempt;
|
|
52
|
+
const capped = Math.min(exponential, max);
|
|
53
|
+
return capped * (0.5 + Math.random() * 0.5);
|
|
54
|
+
}
|
|
55
|
+
async function resumableUpload(uploadUrl, filePath, contentType, ctx, options) {
|
|
56
|
+
const chunkSize = resolveChunkSize(options?.chunkSize);
|
|
57
|
+
const maxResumeAttempts = options?.maxResumeAttempts ?? 5;
|
|
58
|
+
const onProgress = options?.onProgress;
|
|
59
|
+
const fileStats = await stat(filePath);
|
|
60
|
+
const totalBytes = fileStats.size;
|
|
61
|
+
let sessionUri = options?.resumeSessionUri;
|
|
62
|
+
if (!sessionUri) {
|
|
63
|
+
sessionUri = await initiateSession(uploadUrl, contentType, totalBytes, ctx);
|
|
64
|
+
}
|
|
65
|
+
const startTime = Date.now();
|
|
66
|
+
let offset = 0;
|
|
67
|
+
if (options?.resumeSessionUri) {
|
|
68
|
+
offset = await queryProgress(sessionUri, totalBytes, ctx);
|
|
69
|
+
}
|
|
70
|
+
let fh;
|
|
71
|
+
try {
|
|
72
|
+
fh = await open(filePath, "r");
|
|
73
|
+
const chunkBuffer = Buffer.alloc(chunkSize);
|
|
74
|
+
while (offset < totalBytes) {
|
|
75
|
+
const remaining = totalBytes - offset;
|
|
76
|
+
const bytesToRead = Math.min(chunkSize, remaining);
|
|
77
|
+
const { bytesRead } = await fh.read(chunkBuffer, 0, bytesToRead, offset);
|
|
78
|
+
if (bytesRead === 0) break;
|
|
79
|
+
const chunk = Buffer.from(chunkBuffer.buffer, chunkBuffer.byteOffset, bytesRead);
|
|
80
|
+
const rangeEnd = offset + bytesRead - 1;
|
|
81
|
+
const contentRange = `bytes ${offset}-${rangeEnd}/${totalBytes}`;
|
|
82
|
+
let result;
|
|
83
|
+
for (let attempt = 0; attempt <= maxResumeAttempts; attempt++) {
|
|
84
|
+
if (attempt > 0) {
|
|
85
|
+
const delay = jitteredDelay(1e3, attempt - 1, 3e4);
|
|
86
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
87
|
+
try {
|
|
88
|
+
const serverOffset = await queryProgress(sessionUri, totalBytes, ctx);
|
|
89
|
+
if (serverOffset >= offset + bytesRead) {
|
|
90
|
+
result = { complete: false };
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (serverOffset > offset) {
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
ctx.onRetry?.({
|
|
98
|
+
attempt,
|
|
99
|
+
method: "PUT",
|
|
100
|
+
path: sessionUri,
|
|
101
|
+
error: `Chunk upload failed at offset ${offset}, retrying`,
|
|
102
|
+
delayMs: Math.round(delay),
|
|
103
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
result = await sendChunk(sessionUri, chunk, contentRange, ctx);
|
|
107
|
+
if (result) break;
|
|
108
|
+
}
|
|
109
|
+
if (!result) {
|
|
110
|
+
throw new PlayApiError(
|
|
111
|
+
`Upload failed: chunk at offset ${offset} could not be sent after ${maxResumeAttempts + 1} attempts`,
|
|
112
|
+
"UPLOAD_CHUNK_FAILED",
|
|
113
|
+
void 0,
|
|
114
|
+
`The upload session is still valid for up to 1 week. Resume with: --resume-uri "${sessionUri}"`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
offset += bytesRead;
|
|
118
|
+
if (onProgress) {
|
|
119
|
+
const elapsed = (Date.now() - startTime) / 1e3;
|
|
120
|
+
const bytesPerSecond = elapsed > 0 ? offset / elapsed : 0;
|
|
121
|
+
const remainingBytes = totalBytes - offset;
|
|
122
|
+
const etaSeconds = bytesPerSecond > 0 ? remainingBytes / bytesPerSecond : 0;
|
|
123
|
+
onProgress({
|
|
124
|
+
bytesUploaded: offset,
|
|
125
|
+
totalBytes,
|
|
126
|
+
percent: Math.round(offset / totalBytes * 100),
|
|
127
|
+
bytesPerSecond: Math.round(bytesPerSecond),
|
|
128
|
+
etaSeconds: Math.round(etaSeconds)
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (result.complete && result.response) {
|
|
132
|
+
return result.response;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
throw new PlayApiError(
|
|
136
|
+
"Upload finished sending all bytes but did not receive a completion response",
|
|
137
|
+
"UPLOAD_NO_COMPLETION",
|
|
138
|
+
void 0,
|
|
139
|
+
"This is unexpected. Try uploading again."
|
|
140
|
+
);
|
|
141
|
+
} finally {
|
|
142
|
+
await fh?.close();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function initiateSession(uploadUrl, contentType, totalBytes, ctx) {
|
|
146
|
+
const token = await ctx.getAccessToken();
|
|
147
|
+
const url = uploadUrl.includes("?") ? `${uploadUrl}&uploadType=resumable` : `${uploadUrl}?uploadType=resumable`;
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
const timer = setTimeout(() => controller.abort(), 6e4);
|
|
150
|
+
let response;
|
|
151
|
+
try {
|
|
152
|
+
response = await fetch(url, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: {
|
|
155
|
+
Authorization: `Bearer ${token}`,
|
|
156
|
+
"X-Upload-Content-Type": contentType,
|
|
157
|
+
"X-Upload-Content-Length": String(totalBytes),
|
|
158
|
+
"Content-Length": "0"
|
|
159
|
+
},
|
|
160
|
+
signal: controller.signal
|
|
161
|
+
});
|
|
162
|
+
} finally {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
}
|
|
165
|
+
if (!response.ok) {
|
|
166
|
+
const body = await response.text();
|
|
167
|
+
throw new PlayApiError(
|
|
168
|
+
`Failed to initiate resumable upload: ${response.status} ${body.slice(0, 200)}`,
|
|
169
|
+
"UPLOAD_INITIATE_FAILED",
|
|
170
|
+
response.status,
|
|
171
|
+
"Check that the package name, edit ID, and credentials are correct."
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const location = response.headers.get("Location");
|
|
175
|
+
if (!location) {
|
|
176
|
+
throw new PlayApiError(
|
|
177
|
+
"Resumable upload initiation did not return a session URI (Location header missing)",
|
|
178
|
+
"UPLOAD_NO_SESSION_URI",
|
|
179
|
+
response.status,
|
|
180
|
+
"This is a Google API issue. Try again."
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return location;
|
|
184
|
+
}
|
|
185
|
+
async function sendChunk(sessionUri, chunk, contentRange, ctx) {
|
|
186
|
+
const token = await ctx.getAccessToken();
|
|
187
|
+
const chunkTimeoutMs = 3e4 + Math.ceil(chunk.byteLength / (1024 * 1024)) * 1e3;
|
|
188
|
+
const controller = new AbortController();
|
|
189
|
+
const timer = setTimeout(() => controller.abort(), chunkTimeoutMs);
|
|
190
|
+
let response;
|
|
191
|
+
try {
|
|
192
|
+
response = await fetch(sessionUri, {
|
|
193
|
+
method: "PUT",
|
|
194
|
+
headers: {
|
|
195
|
+
Authorization: `Bearer ${token}`,
|
|
196
|
+
"Content-Length": String(chunk.byteLength),
|
|
197
|
+
"Content-Range": contentRange
|
|
198
|
+
},
|
|
199
|
+
body: chunk,
|
|
200
|
+
signal: controller.signal
|
|
201
|
+
});
|
|
202
|
+
} catch {
|
|
203
|
+
return void 0;
|
|
204
|
+
} finally {
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
}
|
|
207
|
+
if (response.status === 200 || response.status === 201) {
|
|
208
|
+
const text = await response.text();
|
|
209
|
+
const data = text ? JSON.parse(text) : {};
|
|
210
|
+
return { complete: true, response: { data, status: response.status } };
|
|
211
|
+
}
|
|
212
|
+
if (response.status === 308) {
|
|
213
|
+
await response.body?.cancel();
|
|
214
|
+
return { complete: false };
|
|
215
|
+
}
|
|
216
|
+
if (response.status === 404) {
|
|
217
|
+
throw new PlayApiError(
|
|
218
|
+
"Upload session not found. The session may have expired.",
|
|
219
|
+
"UPLOAD_SESSION_NOT_FOUND",
|
|
220
|
+
404,
|
|
221
|
+
"Start a new upload. Resumable upload sessions are valid for up to 1 week."
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (response.status === 410) {
|
|
225
|
+
throw new PlayApiError(
|
|
226
|
+
"Upload session has expired.",
|
|
227
|
+
"UPLOAD_SESSION_EXPIRED",
|
|
228
|
+
410,
|
|
229
|
+
"Start a new upload from the beginning."
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (response.status === 401) {
|
|
233
|
+
await response.body?.cancel();
|
|
234
|
+
return void 0;
|
|
235
|
+
}
|
|
236
|
+
if (response.status === 429 || response.status >= 500) {
|
|
237
|
+
await response.body?.cancel();
|
|
238
|
+
return void 0;
|
|
239
|
+
}
|
|
240
|
+
const body = await response.text();
|
|
241
|
+
throw new PlayApiError(
|
|
242
|
+
`Upload chunk failed with status ${response.status}: ${body.slice(0, 200)}`,
|
|
243
|
+
`UPLOAD_HTTP_${response.status}`,
|
|
244
|
+
response.status,
|
|
245
|
+
"The upload encountered an unexpected error."
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
async function queryProgress(sessionUri, totalBytes, ctx) {
|
|
249
|
+
const token = await ctx.getAccessToken();
|
|
250
|
+
const response = await fetch(sessionUri, {
|
|
251
|
+
method: "PUT",
|
|
252
|
+
headers: {
|
|
253
|
+
Authorization: `Bearer ${token}`,
|
|
254
|
+
"Content-Length": "0",
|
|
255
|
+
"Content-Range": `bytes */${totalBytes}`
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
if (response.status === 308) {
|
|
259
|
+
await response.body?.cancel();
|
|
260
|
+
const range = response.headers.get("Range");
|
|
261
|
+
if (range) {
|
|
262
|
+
const match = range.match(/bytes=0-(\d+)/);
|
|
263
|
+
if (match) {
|
|
264
|
+
return Number(match[1]) + 1;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
if (response.status === 200 || response.status === 201) {
|
|
270
|
+
await response.body?.cancel();
|
|
271
|
+
return totalBytes;
|
|
272
|
+
}
|
|
273
|
+
if (response.status === 404 || response.status === 410) {
|
|
274
|
+
await response.body?.cancel();
|
|
275
|
+
throw new PlayApiError(
|
|
276
|
+
"Upload session has expired while querying progress.",
|
|
277
|
+
"UPLOAD_SESSION_EXPIRED",
|
|
278
|
+
response.status,
|
|
279
|
+
"Start a new upload from the beginning."
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
await response.body?.cancel();
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/http.ts
|
|
26
287
|
function stripHtml(text) {
|
|
27
288
|
return text.replace(/<[^>]*>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim();
|
|
28
289
|
}
|
|
@@ -40,7 +301,7 @@ function sanitizeErrorBody(body) {
|
|
|
40
301
|
function validateFilePath(filePath) {
|
|
41
302
|
const resolved = resolve(filePath);
|
|
42
303
|
if (!isAbsolute(resolved)) {
|
|
43
|
-
throw new
|
|
304
|
+
throw new PlayApiError(
|
|
44
305
|
"Invalid file path",
|
|
45
306
|
"API_INVALID_PATH",
|
|
46
307
|
void 0,
|
|
@@ -48,21 +309,26 @@ function validateFilePath(filePath) {
|
|
|
48
309
|
);
|
|
49
310
|
}
|
|
50
311
|
if (filePath.includes("\0")) {
|
|
51
|
-
throw new
|
|
312
|
+
throw new PlayApiError(
|
|
313
|
+
"Invalid file path: null bytes not allowed",
|
|
314
|
+
"API_INVALID_PATH",
|
|
315
|
+
void 0,
|
|
316
|
+
"Provide a valid file path without null bytes."
|
|
317
|
+
);
|
|
52
318
|
}
|
|
53
319
|
return resolved;
|
|
54
320
|
}
|
|
55
321
|
var BASE_URL = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications";
|
|
56
322
|
var UPLOAD_BASE_URL = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications";
|
|
57
323
|
var INTERNAL_SHARING_UPLOAD_BASE_URL = "https://androidpublisher.googleapis.com/upload/internalappsharing/v3/applications";
|
|
58
|
-
function
|
|
324
|
+
function envInt2(name) {
|
|
59
325
|
const val = process.env[name];
|
|
60
326
|
if (val === void 0) return void 0;
|
|
61
327
|
const n = Number(val);
|
|
62
328
|
return Number.isFinite(n) ? n : void 0;
|
|
63
329
|
}
|
|
64
330
|
function resolveOption(explicit, envName, fallback) {
|
|
65
|
-
return explicit ??
|
|
331
|
+
return explicit ?? envInt2(envName) ?? fallback;
|
|
66
332
|
}
|
|
67
333
|
function mapStatusToError(status, body) {
|
|
68
334
|
switch (status) {
|
|
@@ -115,17 +381,17 @@ function mapStatusToError(status, body) {
|
|
|
115
381
|
}
|
|
116
382
|
}
|
|
117
383
|
function isRetryable(status) {
|
|
118
|
-
return status === 429 || status >= 500;
|
|
384
|
+
return status === 408 || status === 429 || status >= 500;
|
|
119
385
|
}
|
|
120
|
-
function
|
|
386
|
+
function jitteredDelay2(base, attempt, max) {
|
|
121
387
|
const exponential = base * 2 ** attempt;
|
|
122
388
|
const capped = Math.min(exponential, max);
|
|
123
389
|
return capped * (0.5 + Math.random() * 0.5);
|
|
124
390
|
}
|
|
125
391
|
function createHttpClient(options) {
|
|
126
|
-
const maxRetries = resolveOption(options.maxRetries, "GPC_MAX_RETRIES",
|
|
392
|
+
const maxRetries = resolveOption(options.maxRetries, "GPC_MAX_RETRIES", 5);
|
|
127
393
|
const timeout = resolveOption(options.timeout, "GPC_TIMEOUT", 3e4);
|
|
128
|
-
const uploadTimeoutExplicit = options.uploadTimeout ??
|
|
394
|
+
const uploadTimeoutExplicit = options.uploadTimeout ?? envInt2("GPC_UPLOAD_TIMEOUT");
|
|
129
395
|
const baseDelay = resolveOption(options.baseDelay, "GPC_BASE_DELAY", 1e3);
|
|
130
396
|
const maxDelay = resolveOption(options.maxDelay, "GPC_MAX_DELAY", 6e4);
|
|
131
397
|
const onRetry = options.onRetry;
|
|
@@ -139,7 +405,7 @@ function createHttpClient(options) {
|
|
|
139
405
|
let lastError;
|
|
140
406
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
141
407
|
if (attempt > 0) {
|
|
142
|
-
const delay =
|
|
408
|
+
const delay = jitteredDelay2(baseDelay, attempt - 1, maxDelay);
|
|
143
409
|
await new Promise((r) => setTimeout(r, delay));
|
|
144
410
|
}
|
|
145
411
|
const controller = new AbortController();
|
|
@@ -168,7 +434,7 @@ function createHttpClient(options) {
|
|
|
168
434
|
}
|
|
169
435
|
const errorBody = await response.text();
|
|
170
436
|
const { code, suggestion } = mapStatusToError(response.status, errorBody);
|
|
171
|
-
const err = new
|
|
437
|
+
const err = new PlayApiError(
|
|
172
438
|
`${method} ${path} failed with status ${response.status}: ${sanitizeErrorBody(errorBody)}`,
|
|
173
439
|
code,
|
|
174
440
|
response.status,
|
|
@@ -176,7 +442,7 @@ function createHttpClient(options) {
|
|
|
176
442
|
);
|
|
177
443
|
if (isRetryable(response.status) && attempt < maxRetries) {
|
|
178
444
|
lastError = err;
|
|
179
|
-
const delay =
|
|
445
|
+
const delay = jitteredDelay2(baseDelay, attempt, maxDelay);
|
|
180
446
|
onRetry?.({
|
|
181
447
|
attempt: attempt + 1,
|
|
182
448
|
method,
|
|
@@ -195,11 +461,11 @@ function createHttpClient(options) {
|
|
|
195
461
|
}
|
|
196
462
|
throw err;
|
|
197
463
|
} catch (error) {
|
|
198
|
-
if (error instanceof
|
|
464
|
+
if (error instanceof PlayApiError) {
|
|
199
465
|
throw error;
|
|
200
466
|
}
|
|
201
467
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
202
|
-
const timeoutErr = new
|
|
468
|
+
const timeoutErr = new PlayApiError(
|
|
203
469
|
`${method} ${path} timed out after ${timeout}ms`,
|
|
204
470
|
"API_TIMEOUT",
|
|
205
471
|
void 0,
|
|
@@ -212,14 +478,14 @@ function createHttpClient(options) {
|
|
|
212
478
|
method,
|
|
213
479
|
path,
|
|
214
480
|
error: timeoutErr.message,
|
|
215
|
-
delayMs: Math.round(
|
|
481
|
+
delayMs: Math.round(jitteredDelay2(baseDelay, attempt, maxDelay)),
|
|
216
482
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
217
483
|
});
|
|
218
484
|
continue;
|
|
219
485
|
}
|
|
220
486
|
throw timeoutErr;
|
|
221
487
|
}
|
|
222
|
-
const networkErr = new
|
|
488
|
+
const networkErr = new PlayApiError(
|
|
223
489
|
`${method} ${path} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
224
490
|
"API_NETWORK_ERROR",
|
|
225
491
|
void 0,
|
|
@@ -232,7 +498,7 @@ function createHttpClient(options) {
|
|
|
232
498
|
method,
|
|
233
499
|
path,
|
|
234
500
|
error: networkErr.message,
|
|
235
|
-
delayMs: Math.round(
|
|
501
|
+
delayMs: Math.round(jitteredDelay2(baseDelay, attempt, maxDelay)),
|
|
236
502
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
237
503
|
});
|
|
238
504
|
continue;
|
|
@@ -242,7 +508,12 @@ function createHttpClient(options) {
|
|
|
242
508
|
clearTimeout(timer);
|
|
243
509
|
}
|
|
244
510
|
}
|
|
245
|
-
throw lastError ?? new
|
|
511
|
+
throw lastError ?? new PlayApiError(
|
|
512
|
+
"Request failed",
|
|
513
|
+
"API_NETWORK_ERROR",
|
|
514
|
+
void 0,
|
|
515
|
+
"Check your network connection and try again. Use --verbose for details."
|
|
516
|
+
);
|
|
246
517
|
}
|
|
247
518
|
function computeUploadTimeout(fileSizeBytes) {
|
|
248
519
|
if (uploadTimeoutExplicit !== void 0) return uploadTimeoutExplicit;
|
|
@@ -258,7 +529,7 @@ function createHttpClient(options) {
|
|
|
258
529
|
let lastError;
|
|
259
530
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
260
531
|
if (attempt > 0) {
|
|
261
|
-
const delay =
|
|
532
|
+
const delay = jitteredDelay2(baseDelay, attempt - 1, maxDelay);
|
|
262
533
|
await new Promise((r) => setTimeout(r, delay));
|
|
263
534
|
}
|
|
264
535
|
const controller = new AbortController();
|
|
@@ -284,7 +555,7 @@ function createHttpClient(options) {
|
|
|
284
555
|
}
|
|
285
556
|
const errorBody = await response.text();
|
|
286
557
|
const { code, suggestion } = mapStatusToError(response.status, errorBody);
|
|
287
|
-
const err = new
|
|
558
|
+
const err = new PlayApiError(
|
|
288
559
|
`POST upload ${path} failed with status ${response.status}: ${sanitizeErrorBody(errorBody)}`,
|
|
289
560
|
code,
|
|
290
561
|
response.status,
|
|
@@ -292,7 +563,7 @@ function createHttpClient(options) {
|
|
|
292
563
|
);
|
|
293
564
|
if (isRetryable(response.status) && attempt < maxRetries) {
|
|
294
565
|
lastError = err;
|
|
295
|
-
const delay =
|
|
566
|
+
const delay = jitteredDelay2(baseDelay, attempt, maxDelay);
|
|
296
567
|
onRetry?.({
|
|
297
568
|
attempt: attempt + 1,
|
|
298
569
|
method: "POST",
|
|
@@ -311,12 +582,12 @@ function createHttpClient(options) {
|
|
|
311
582
|
}
|
|
312
583
|
throw err;
|
|
313
584
|
} catch (error) {
|
|
314
|
-
if (error instanceof
|
|
585
|
+
if (error instanceof PlayApiError) {
|
|
315
586
|
throw error;
|
|
316
587
|
}
|
|
317
588
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
318
589
|
const sizeMb = Math.round(fileBuffer.byteLength / (1024 * 1024));
|
|
319
|
-
const timeoutErr = new
|
|
590
|
+
const timeoutErr = new PlayApiError(
|
|
320
591
|
`POST upload ${path} timed out after ${effectiveTimeout}ms (file: ${sizeMb} MB)`,
|
|
321
592
|
"API_TIMEOUT",
|
|
322
593
|
void 0,
|
|
@@ -329,14 +600,14 @@ function createHttpClient(options) {
|
|
|
329
600
|
method: "POST",
|
|
330
601
|
path: `upload ${path}`,
|
|
331
602
|
error: timeoutErr.message,
|
|
332
|
-
delayMs: Math.round(
|
|
603
|
+
delayMs: Math.round(jitteredDelay2(baseDelay, attempt, maxDelay)),
|
|
333
604
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
334
605
|
});
|
|
335
606
|
continue;
|
|
336
607
|
}
|
|
337
608
|
throw timeoutErr;
|
|
338
609
|
}
|
|
339
|
-
const networkErr = new
|
|
610
|
+
const networkErr = new PlayApiError(
|
|
340
611
|
`POST upload ${path} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
341
612
|
"API_NETWORK_ERROR",
|
|
342
613
|
void 0,
|
|
@@ -349,7 +620,7 @@ function createHttpClient(options) {
|
|
|
349
620
|
method: "POST",
|
|
350
621
|
path: `upload ${path}`,
|
|
351
622
|
error: networkErr.message,
|
|
352
|
-
delayMs: Math.round(
|
|
623
|
+
delayMs: Math.round(jitteredDelay2(baseDelay, attempt, maxDelay)),
|
|
353
624
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
354
625
|
});
|
|
355
626
|
continue;
|
|
@@ -359,7 +630,12 @@ function createHttpClient(options) {
|
|
|
359
630
|
clearTimeout(timer);
|
|
360
631
|
}
|
|
361
632
|
}
|
|
362
|
-
throw lastError ?? new
|
|
633
|
+
throw lastError ?? new PlayApiError(
|
|
634
|
+
"Upload request failed",
|
|
635
|
+
"API_NETWORK_ERROR",
|
|
636
|
+
void 0,
|
|
637
|
+
"Check your network connection and try again. Use --verbose for details."
|
|
638
|
+
);
|
|
363
639
|
}
|
|
364
640
|
return {
|
|
365
641
|
get(path, params) {
|
|
@@ -380,6 +656,25 @@ function createHttpClient(options) {
|
|
|
380
656
|
upload(path, filePath, contentType) {
|
|
381
657
|
return uploadRequest(path, filePath, contentType);
|
|
382
658
|
},
|
|
659
|
+
async uploadResumable(path, filePath, contentType, uploadOptions) {
|
|
660
|
+
const safeFilePath = validateFilePath(filePath);
|
|
661
|
+
const fileStats = await stat2(safeFilePath);
|
|
662
|
+
const threshold = envInt2("GPC_UPLOAD_RESUMABLE_THRESHOLD") ?? RESUMABLE_THRESHOLD;
|
|
663
|
+
if (fileStats.size < threshold && !uploadOptions?.resumeSessionUri) {
|
|
664
|
+
uploadOptions?.onProgress?.({ bytesUploaded: 0, totalBytes: fileStats.size, percent: 0, bytesPerSecond: 0, etaSeconds: 0 });
|
|
665
|
+
const result = await uploadRequest(path, safeFilePath, contentType);
|
|
666
|
+
uploadOptions?.onProgress?.({ bytesUploaded: fileStats.size, totalBytes: fileStats.size, percent: 100, bytesPerSecond: 0, etaSeconds: 0 });
|
|
667
|
+
return result;
|
|
668
|
+
}
|
|
669
|
+
const uploadUrl = `${UPLOAD_BASE_URL}${path}`;
|
|
670
|
+
return resumableUpload(uploadUrl, safeFilePath, contentType, {
|
|
671
|
+
getAccessToken: () => options.auth.getAccessToken(),
|
|
672
|
+
maxRetries,
|
|
673
|
+
baseDelay,
|
|
674
|
+
maxDelay,
|
|
675
|
+
onRetry
|
|
676
|
+
}, uploadOptions);
|
|
677
|
+
},
|
|
383
678
|
uploadInternal(path, filePath, contentType) {
|
|
384
679
|
return uploadRequest(path, filePath, contentType, INTERNAL_SHARING_UPLOAD_BASE_URL);
|
|
385
680
|
},
|
|
@@ -402,7 +697,7 @@ function createHttpClient(options) {
|
|
|
402
697
|
if (!response.ok) {
|
|
403
698
|
const errorBody = await response.text();
|
|
404
699
|
const { code, suggestion } = mapStatusToError(response.status, errorBody);
|
|
405
|
-
throw new
|
|
700
|
+
throw new PlayApiError(
|
|
406
701
|
`GET ${path} failed with status ${response.status}: ${sanitizeErrorBody(errorBody)}`,
|
|
407
702
|
code,
|
|
408
703
|
response.status,
|
|
@@ -473,14 +768,15 @@ function createApiClient(options) {
|
|
|
473
768
|
);
|
|
474
769
|
return data.bundles;
|
|
475
770
|
},
|
|
476
|
-
async upload(packageName, editId, filePath) {
|
|
477
|
-
const { data } = await http.
|
|
771
|
+
async upload(packageName, editId, filePath, uploadOptions) {
|
|
772
|
+
const { data } = await http.uploadResumable(
|
|
478
773
|
`/${packageName}/edits/${editId}/bundles`,
|
|
479
774
|
filePath,
|
|
480
|
-
"application/octet-stream"
|
|
775
|
+
"application/octet-stream",
|
|
776
|
+
uploadOptions
|
|
481
777
|
);
|
|
482
778
|
if (!data || !data.versionCode) {
|
|
483
|
-
throw new
|
|
779
|
+
throw new PlayApiError(
|
|
484
780
|
"Upload succeeded but no bundle data returned",
|
|
485
781
|
"API_EMPTY_RESPONSE",
|
|
486
782
|
200,
|
|
@@ -572,7 +868,7 @@ function createApiClient(options) {
|
|
|
572
868
|
filePath.endsWith(".png") ? "image/png" : "image/jpeg"
|
|
573
869
|
);
|
|
574
870
|
if (!data.image) {
|
|
575
|
-
throw new
|
|
871
|
+
throw new PlayApiError(
|
|
576
872
|
"Upload succeeded but no image data returned",
|
|
577
873
|
"API_EMPTY_RESPONSE",
|
|
578
874
|
200,
|
|
@@ -603,16 +899,11 @@ function createApiClient(options) {
|
|
|
603
899
|
},
|
|
604
900
|
dataSafety: {
|
|
605
901
|
async get(packageName) {
|
|
606
|
-
const { data } = await http.get(
|
|
607
|
-
`/${packageName}/dataSafety`
|
|
608
|
-
);
|
|
902
|
+
const { data } = await http.get(`/${packageName}/dataSafety`);
|
|
609
903
|
return data;
|
|
610
904
|
},
|
|
611
905
|
async update(packageName, body) {
|
|
612
|
-
const { data } = await http.put(
|
|
613
|
-
`/${packageName}/dataSafety`,
|
|
614
|
-
body
|
|
615
|
-
);
|
|
906
|
+
const { data } = await http.put(`/${packageName}/dataSafety`, body);
|
|
616
907
|
return data;
|
|
617
908
|
}
|
|
618
909
|
},
|
|
@@ -665,9 +956,7 @@ function createApiClient(options) {
|
|
|
665
956
|
return data;
|
|
666
957
|
},
|
|
667
958
|
async get(packageName, productId) {
|
|
668
|
-
const { data } = await http.get(
|
|
669
|
-
`/${packageName}/subscriptions/${productId}`
|
|
670
|
-
);
|
|
959
|
+
const { data } = await http.get(`/${packageName}/subscriptions/${productId}`);
|
|
671
960
|
return data;
|
|
672
961
|
},
|
|
673
962
|
async create(packageName, body, productId) {
|
|
@@ -702,9 +991,7 @@ function createApiClient(options) {
|
|
|
702
991
|
return data;
|
|
703
992
|
},
|
|
704
993
|
async deleteBasePlan(packageName, productId, basePlanId) {
|
|
705
|
-
await http.delete(
|
|
706
|
-
`/${packageName}/subscriptions/${productId}/basePlans/${basePlanId}`
|
|
707
|
-
);
|
|
994
|
+
await http.delete(`/${packageName}/subscriptions/${productId}/basePlans/${basePlanId}`);
|
|
708
995
|
},
|
|
709
996
|
async migratePrices(packageName, productId, basePlanId, body) {
|
|
710
997
|
const { data } = await http.post(
|
|
@@ -763,7 +1050,6 @@ function createApiClient(options) {
|
|
|
763
1050
|
async list(packageName, options2) {
|
|
764
1051
|
const params = {};
|
|
765
1052
|
if (options2?.token) params["token"] = options2.token;
|
|
766
|
-
if (options2?.maxResults) params["maxResults"] = String(options2.maxResults);
|
|
767
1053
|
const hasParams = Object.keys(params).length > 0;
|
|
768
1054
|
const { data } = await http.get(
|
|
769
1055
|
`/${packageName}/inappproducts`,
|
|
@@ -837,6 +1123,12 @@ function createApiClient(options) {
|
|
|
837
1123
|
return data;
|
|
838
1124
|
},
|
|
839
1125
|
async getSubscriptionV1(packageName, subscriptionId, token) {
|
|
1126
|
+
if (typeof process !== "undefined" && process.emitWarning) {
|
|
1127
|
+
process.emitWarning(
|
|
1128
|
+
"purchases.subscriptions.get (v1) is deprecated by Google (shutdown Aug 2027). Use getSubscriptionV2() instead.",
|
|
1129
|
+
"DeprecationWarning"
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
840
1132
|
const { data } = await http.get(
|
|
841
1133
|
`/${packageName}/purchases/subscriptions/${subscriptionId}/tokens/${token}`
|
|
842
1134
|
);
|
|
@@ -922,7 +1214,7 @@ function createApiClient(options) {
|
|
|
922
1214
|
"application/octet-stream"
|
|
923
1215
|
);
|
|
924
1216
|
if (!data.deobfuscationFile) {
|
|
925
|
-
throw new
|
|
1217
|
+
throw new PlayApiError(
|
|
926
1218
|
"Upload succeeded but no deobfuscation file data returned",
|
|
927
1219
|
"API_EMPTY_RESPONSE",
|
|
928
1220
|
200,
|
|
@@ -1084,10 +1376,7 @@ function createApiClient(options) {
|
|
|
1084
1376
|
return data;
|
|
1085
1377
|
},
|
|
1086
1378
|
async create(packageName, body) {
|
|
1087
|
-
const { data } = await http.post(
|
|
1088
|
-
`/${packageName}/purchaseOptions`,
|
|
1089
|
-
body
|
|
1090
|
-
);
|
|
1379
|
+
const { data } = await http.post(`/${packageName}/purchaseOptions`, body);
|
|
1091
1380
|
return data;
|
|
1092
1381
|
},
|
|
1093
1382
|
async activate(packageName, purchaseOptionId) {
|
|
@@ -1129,10 +1418,71 @@ function createApiClient(options) {
|
|
|
1129
1418
|
return data.generatedApks || [];
|
|
1130
1419
|
},
|
|
1131
1420
|
async download(packageName, versionCode, id) {
|
|
1132
|
-
return http.download(
|
|
1133
|
-
|
|
1134
|
-
|
|
1421
|
+
return http.download(`/${packageName}/generatedApks/${versionCode}/download/${id}`);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// src/rate-limiter.ts
|
|
1428
|
+
var RATE_LIMIT_BUCKETS = {
|
|
1429
|
+
default: { name: "default", maxTokens: 200, refillRate: 200, refillIntervalMs: 1e3 },
|
|
1430
|
+
reviewsGet: { name: "reviewsGet", maxTokens: 200, refillRate: 200, refillIntervalMs: 36e5 },
|
|
1431
|
+
reviewsPost: {
|
|
1432
|
+
name: "reviewsPost",
|
|
1433
|
+
maxTokens: 2e3,
|
|
1434
|
+
refillRate: 2e3,
|
|
1435
|
+
refillIntervalMs: 864e5
|
|
1436
|
+
},
|
|
1437
|
+
voidedBurst: { name: "voidedBurst", maxTokens: 30, refillRate: 30, refillIntervalMs: 3e4 },
|
|
1438
|
+
voidedDaily: {
|
|
1439
|
+
name: "voidedDaily",
|
|
1440
|
+
maxTokens: 6e3,
|
|
1441
|
+
refillRate: 6e3,
|
|
1442
|
+
refillIntervalMs: 864e5
|
|
1443
|
+
},
|
|
1444
|
+
reporting: { name: "reporting", maxTokens: 10, refillRate: 10, refillIntervalMs: 1e3 }
|
|
1445
|
+
};
|
|
1446
|
+
function createRateLimiter(buckets) {
|
|
1447
|
+
const states = /* @__PURE__ */ new Map();
|
|
1448
|
+
if (buckets) {
|
|
1449
|
+
for (const bucket of buckets) {
|
|
1450
|
+
states.set(bucket.name, {
|
|
1451
|
+
tokens: bucket.maxTokens,
|
|
1452
|
+
lastRefillTime: Date.now(),
|
|
1453
|
+
config: bucket
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return {
|
|
1458
|
+
async acquire(bucket) {
|
|
1459
|
+
const state = states.get(bucket);
|
|
1460
|
+
if (!state) return;
|
|
1461
|
+
const now = Date.now();
|
|
1462
|
+
const elapsed = now - state.lastRefillTime;
|
|
1463
|
+
const refill = Math.floor(
|
|
1464
|
+
elapsed / state.config.refillIntervalMs * state.config.refillRate
|
|
1465
|
+
);
|
|
1466
|
+
if (refill > 0) {
|
|
1467
|
+
state.tokens = Math.min(state.config.maxTokens, state.tokens + refill);
|
|
1468
|
+
state.lastRefillTime = now;
|
|
1135
1469
|
}
|
|
1470
|
+
if (state.tokens > 0) {
|
|
1471
|
+
state.tokens--;
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const tokensNeeded = 1;
|
|
1475
|
+
const waitMs = Math.ceil(
|
|
1476
|
+
tokensNeeded / state.config.refillRate * state.config.refillIntervalMs
|
|
1477
|
+
);
|
|
1478
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
1479
|
+
const afterWait = Date.now();
|
|
1480
|
+
const totalElapsed = afterWait - state.lastRefillTime;
|
|
1481
|
+
const newTokens = Math.floor(
|
|
1482
|
+
totalElapsed / state.config.refillIntervalMs * state.config.refillRate
|
|
1483
|
+
);
|
|
1484
|
+
state.tokens = Math.min(state.config.maxTokens, newTokens) - 1;
|
|
1485
|
+
state.lastRefillTime = afterWait;
|
|
1136
1486
|
}
|
|
1137
1487
|
};
|
|
1138
1488
|
}
|
|
@@ -1141,8 +1491,10 @@ function createApiClient(options) {
|
|
|
1141
1491
|
var REPORTING_BASE_URL = "https://playdeveloperreporting.googleapis.com/v1beta1";
|
|
1142
1492
|
function createReportingClient(options) {
|
|
1143
1493
|
const http = createHttpClient({ ...options, baseUrl: REPORTING_BASE_URL });
|
|
1494
|
+
const limiter = options.rateLimiter ?? createRateLimiter([RATE_LIMIT_BUCKETS["reporting"]]);
|
|
1144
1495
|
return {
|
|
1145
1496
|
async queryMetricSet(packageName, metricSet, query) {
|
|
1497
|
+
await limiter.acquire("reporting");
|
|
1146
1498
|
const { data } = await http.post(
|
|
1147
1499
|
`/apps/${packageName}/${metricSet}:query`,
|
|
1148
1500
|
query
|
|
@@ -1150,10 +1502,12 @@ function createReportingClient(options) {
|
|
|
1150
1502
|
return data;
|
|
1151
1503
|
},
|
|
1152
1504
|
async getAnomalies(packageName) {
|
|
1505
|
+
await limiter.acquire("reporting");
|
|
1153
1506
|
const { data } = await http.get(`/apps/${packageName}/anomalies`);
|
|
1154
1507
|
return data;
|
|
1155
1508
|
},
|
|
1156
1509
|
async searchErrorIssues(packageName, filter, pageSize, pageToken) {
|
|
1510
|
+
await limiter.acquire("reporting");
|
|
1157
1511
|
const params = {};
|
|
1158
1512
|
if (filter) params["filter"] = filter;
|
|
1159
1513
|
if (pageSize) params["pageSize"] = String(pageSize);
|
|
@@ -1165,6 +1519,7 @@ function createReportingClient(options) {
|
|
|
1165
1519
|
return data;
|
|
1166
1520
|
},
|
|
1167
1521
|
async searchErrorReports(packageName, issueName, pageSize, pageToken) {
|
|
1522
|
+
await limiter.acquire("reporting");
|
|
1168
1523
|
const params = {};
|
|
1169
1524
|
if (pageSize) params["pageSize"] = String(pageSize);
|
|
1170
1525
|
if (pageToken) params["pageToken"] = pageToken;
|
|
@@ -1301,84 +1656,17 @@ function createEnterpriseClient(options) {
|
|
|
1301
1656
|
return {
|
|
1302
1657
|
apps: {
|
|
1303
1658
|
async create(organizationId, app) {
|
|
1304
|
-
const { data } = await http.post(
|
|
1305
|
-
`/${organizationId}/apps`,
|
|
1306
|
-
app
|
|
1307
|
-
);
|
|
1659
|
+
const { data } = await http.post(`/${organizationId}/apps`, app);
|
|
1308
1660
|
return data;
|
|
1309
1661
|
},
|
|
1310
1662
|
async list(organizationId) {
|
|
1311
|
-
const { data } = await http.get(
|
|
1312
|
-
`/${organizationId}/apps`
|
|
1313
|
-
);
|
|
1663
|
+
const { data } = await http.get(`/${organizationId}/apps`);
|
|
1314
1664
|
return data;
|
|
1315
1665
|
}
|
|
1316
1666
|
}
|
|
1317
1667
|
};
|
|
1318
1668
|
}
|
|
1319
1669
|
|
|
1320
|
-
// src/rate-limiter.ts
|
|
1321
|
-
var RATE_LIMIT_BUCKETS = {
|
|
1322
|
-
default: { name: "default", maxTokens: 200, refillRate: 200, refillIntervalMs: 1e3 },
|
|
1323
|
-
reviewsGet: { name: "reviewsGet", maxTokens: 200, refillRate: 200, refillIntervalMs: 36e5 },
|
|
1324
|
-
reviewsPost: {
|
|
1325
|
-
name: "reviewsPost",
|
|
1326
|
-
maxTokens: 2e3,
|
|
1327
|
-
refillRate: 2e3,
|
|
1328
|
-
refillIntervalMs: 864e5
|
|
1329
|
-
},
|
|
1330
|
-
voidedBurst: { name: "voidedBurst", maxTokens: 30, refillRate: 30, refillIntervalMs: 3e4 },
|
|
1331
|
-
voidedDaily: {
|
|
1332
|
-
name: "voidedDaily",
|
|
1333
|
-
maxTokens: 6e3,
|
|
1334
|
-
refillRate: 6e3,
|
|
1335
|
-
refillIntervalMs: 864e5
|
|
1336
|
-
}
|
|
1337
|
-
};
|
|
1338
|
-
function createRateLimiter(buckets) {
|
|
1339
|
-
const states = /* @__PURE__ */ new Map();
|
|
1340
|
-
if (buckets) {
|
|
1341
|
-
for (const bucket of buckets) {
|
|
1342
|
-
states.set(bucket.name, {
|
|
1343
|
-
tokens: bucket.maxTokens,
|
|
1344
|
-
lastRefillTime: Date.now(),
|
|
1345
|
-
config: bucket
|
|
1346
|
-
});
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
return {
|
|
1350
|
-
async acquire(bucket) {
|
|
1351
|
-
const state = states.get(bucket);
|
|
1352
|
-
if (!state) return;
|
|
1353
|
-
const now = Date.now();
|
|
1354
|
-
const elapsed = now - state.lastRefillTime;
|
|
1355
|
-
const refill = Math.floor(
|
|
1356
|
-
elapsed / state.config.refillIntervalMs * state.config.refillRate
|
|
1357
|
-
);
|
|
1358
|
-
if (refill > 0) {
|
|
1359
|
-
state.tokens = Math.min(state.config.maxTokens, state.tokens + refill);
|
|
1360
|
-
state.lastRefillTime = now;
|
|
1361
|
-
}
|
|
1362
|
-
if (state.tokens > 0) {
|
|
1363
|
-
state.tokens--;
|
|
1364
|
-
return;
|
|
1365
|
-
}
|
|
1366
|
-
const tokensNeeded = 1;
|
|
1367
|
-
const waitMs = Math.ceil(
|
|
1368
|
-
tokensNeeded / state.config.refillRate * state.config.refillIntervalMs
|
|
1369
|
-
);
|
|
1370
|
-
await new Promise((r) => setTimeout(r, waitMs));
|
|
1371
|
-
const afterWait = Date.now();
|
|
1372
|
-
const totalElapsed = afterWait - state.lastRefillTime;
|
|
1373
|
-
const newTokens = Math.floor(
|
|
1374
|
-
totalElapsed / state.config.refillIntervalMs * state.config.refillRate
|
|
1375
|
-
);
|
|
1376
|
-
state.tokens = Math.min(state.config.maxTokens, newTokens) - 1;
|
|
1377
|
-
state.lastRefillTime = afterWait;
|
|
1378
|
-
}
|
|
1379
|
-
};
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
1670
|
// src/paginate.ts
|
|
1383
1671
|
async function* paginate(fetchPage, options) {
|
|
1384
1672
|
let pageToken = options?.startPageToken;
|
|
@@ -1431,8 +1719,9 @@ async function paginateParallel(fetchPage, pageTokens, concurrency = 4) {
|
|
|
1431
1719
|
return { items: allItems, nextPageToken: lastNextPageToken };
|
|
1432
1720
|
}
|
|
1433
1721
|
export {
|
|
1434
|
-
|
|
1722
|
+
PlayApiError,
|
|
1435
1723
|
RATE_LIMIT_BUCKETS,
|
|
1724
|
+
RESUMABLE_THRESHOLD,
|
|
1436
1725
|
createApiClient,
|
|
1437
1726
|
createEnterpriseClient,
|
|
1438
1727
|
createGamesClient,
|