@domandigital/gbp 0.5.0 → 0.6.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7437827: A dead Google token or a Google 503 no longer reaches the page, and no longer files a new error-tracker issue every time.
8
+
9
+ - **`getBusinessReviewsSafe(options)` and `getPublishedReviewsSafe(options)`** never throw. On any failure they serve the last good result this process fetched (up to `maxStaleMs`, default 30 days), then your `fallback` (a snapshot, or a loader such as a KV read), then an empty result. `source` says which (`live`, `cache`, `fallback`, `empty`) and `error` holds the failure.
10
+ - They report each kind of failure at most once per `reportIntervalMs` (default one hour) per process, through `report(error, { fingerprint, context, transient, served, suppressed })`. Pass `fingerprint` and `context` straight to `Sentry.captureException`.
11
+ - After `invalid_grant` or `invalid_client` they skip the token request for `authRetryMs` (default five minutes) and serve the cache or fallback straight away.
12
+ - **Error messages are now short and stable**, with what varies moved to `error.context`: `GBP token refresh failed: invalid_grant` (was `Google OAuth token refresh failed: 400 invalid_grant (...)`), `GBP reviews.list failed: 5xx` (was the status plus Google's body), `GBP reviews.list timed out`. A published-file HTTP failure no longer puts the body in its message. Anything matching on the old message text needs updating; match on `error.code` instead.
13
+ - Every `GbpError` has `code`, `transient`, `context` and `fingerprint` (`["gbp", code]`). Every 5xx shares the code `api_5xx`, so a Google outage is one issue rather than one per status. `GbpApiError` also has `body`.
14
+
3
15
  ## 0.5.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -227,7 +227,45 @@ Options:
227
227
  Never throws on missing configuration -- `isBusinessProfileConfigured()` gates
228
228
  internally and returns an empty result, so UI can render unconditionally.
229
229
  Does throw on a real failure, so a calling route should catch and degrade
230
- explicitly if it wants zero-downtime behaviour on a Google outage.
230
+ explicitly if it wants zero-downtime behaviour on a Google outage, or use
231
+ `getBusinessReviewsSafe`, which does that for it.
232
+
233
+ ### `getBusinessReviewsSafe(options?)` and `getPublishedReviewsSafe(options)`: reviews that never throw
234
+
235
+ Use these in a page render. They take the same options as `getBusinessReviews`
236
+ and `getPublishedReviews`, catch every failure, and serve in order: the last
237
+ good result this process fetched for the same options (up to `maxStaleMs`,
238
+ default 30 days), then your `fallback`, then an empty result, which has a
239
+ `null` rating and count and an empty `reviews` list. `source` says which:
240
+ `"live"`, `"cache"`, `"fallback"` or `"empty"`, and `error` holds the failure
241
+ when there was one.
242
+
243
+ ```ts
244
+ import * as Sentry from "@sentry/nextjs";
245
+ import { getBusinessReviewsSafe } from "@domandigital/gbp";
246
+
247
+ const { averageRating, totalReviewCount, reviews, source } = await getBusinessReviewsSafe({
248
+ filterMinStars: 4,
249
+ next: { revalidate: 86400, tags: ["google-reviews"] },
250
+ // A snapshot, or a loader for one (a KV read). Served as given.
251
+ fallback: () => kv.get(LAST_KNOWN_GOOD_KEY),
252
+ report: (error, { fingerprint, context, transient }) =>
253
+ Sentry.captureException(error, { fingerprint, extra: context, level: transient ? "warning" : "error" }),
254
+ });
255
+ if (source === "live") await kv.set(LAST_KNOWN_GOOD_KEY, { averageRating, totalReviewCount, reviews });
256
+ ```
257
+
258
+ - `report` runs at most once per kind of failure per `reportIntervalMs`
259
+ (default one hour) in each process, not on every request. It is told how
260
+ many it held back since the last report (`suppressed`). Without `report`, one
261
+ `console.warn` per window.
262
+ - After `invalid_grant` or `invalid_client` the live call is skipped for
263
+ `authRetryMs` (default five minutes), because retrying cannot fix it.
264
+ - A `fallback` is served as given: `limit` and `filterMinStars` are not
265
+ applied to it. A loader that throws counts as no fallback.
266
+
267
+ A static build that should refuse to publish rather than ship stale or empty
268
+ reviews should keep using the throwing functions.
231
269
 
232
270
  ### Failures, and what each one means
233
271
 
@@ -237,7 +275,13 @@ a few times with backoff; Google documents the 429 for quota
237
275
  A 401 means the cached access token is no longer good: it is dropped,
238
276
  refreshed once, and the page is asked for again; a second 401 throws.
239
277
 
240
- All errors extend `GbpError`, and none carries a credential:
278
+ All errors extend `GbpError`, and none carries a credential. Each message is
279
+ short and stable (`GBP token refresh failed: invalid_grant`, `GBP reviews.list
280
+ failed: 5xx`), so an error tracker files every occurrence under one issue.
281
+ What varies, such as the status, Google's description or a body excerpt, is in
282
+ `error.context`. `error.code` names the failure, `error.fingerprint` is
283
+ `["gbp", code]` to pass to Sentry, and `error.transient` says whether it may
284
+ clear without anyone acting. Every 5xx shares the code `api_5xx`.
241
285
 
242
286
  | Error | When | What to do |
243
287
  |---|---|---|
package/dist/index.cjs CHANGED
@@ -20,8 +20,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ DEFAULT_AUTH_RETRY_MS: () => DEFAULT_AUTH_RETRY_MS,
23
24
  DEFAULT_MAX_PAGES: () => DEFAULT_MAX_PAGES,
25
+ DEFAULT_MAX_STALE_MS: () => DEFAULT_MAX_STALE_MS,
24
26
  DEFAULT_PUBLISHED_REVIEWS_BASE_URL: () => DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
27
+ DEFAULT_REPORT_INTERVAL_MS: () => DEFAULT_REPORT_INTERVAL_MS,
25
28
  DEFAULT_REQUEST_POLICY: () => DEFAULT_REQUEST_POLICY,
26
29
  GbpApiError: () => GbpApiError,
27
30
  GbpAuthError: () => GbpAuthError,
@@ -31,8 +34,10 @@ __export(index_exports, {
31
34
  GbpTimeoutError: () => GbpTimeoutError,
32
35
  PUBLISHED_REVIEWS_SCHEMA: () => PUBLISHED_REVIEWS_SCHEMA,
33
36
  getBusinessReviews: () => getBusinessReviews,
37
+ getBusinessReviewsSafe: () => getBusinessReviewsSafe,
34
38
  getGoogleOAuthAccessToken: () => getGoogleOAuthAccessToken,
35
39
  getPublishedReviews: () => getPublishedReviews,
40
+ getPublishedReviewsSafe: () => getPublishedReviewsSafe,
36
41
  hasGoogleOAuthCredentials: () => hasGoogleOAuthCredentials,
37
42
  invalidateAccessToken: () => invalidateAccessToken,
38
43
  isBusinessProfileConfigured: () => isBusinessProfileConfigured,
@@ -45,17 +50,25 @@ module.exports = __toCommonJS(index_exports);
45
50
 
46
51
  // src/errors.ts
47
52
  var GbpError = class extends Error {
48
- constructor(message) {
53
+ constructor(message, code = "unknown", transient = false, context = {}) {
49
54
  super(message);
50
55
  this.name = new.target.name;
56
+ this.code = code;
57
+ this.transient = transient;
58
+ this.context = context;
59
+ }
60
+ /** `["gbp", code]`: one error-tracker issue per kind of failure. */
61
+ get fingerprint() {
62
+ return ["gbp", this.code];
51
63
  }
52
64
  };
53
65
  var GbpAuthError = class extends GbpError {
54
66
  constructor(code, status, description) {
55
- super(
56
- `Google OAuth token refresh failed: ${status} ${code}${description ? ` (${description})` : ""}` + (code === "invalid_grant" ? ". Reauthorise the Google account and replace GBP_REFRESH_TOKEN." : "")
57
- );
58
- this.code = code;
67
+ super(`GBP token refresh failed: ${code}`, code, code === "token_request_failed" && status >= 500, {
68
+ status,
69
+ ...description ? { description } : {},
70
+ ...code === "invalid_grant" ? { remedy: "Reauthorise the Google account and replace GBP_REFRESH_TOKEN." } : {}
71
+ });
59
72
  this.reauthorizationRequired = code === "invalid_grant";
60
73
  this.status = status;
61
74
  this.description = description;
@@ -63,7 +76,15 @@ var GbpAuthError = class extends GbpError {
63
76
  };
64
77
  var GbpApiError = class extends GbpError {
65
78
  constructor(options) {
66
- super(`Business Profile reviews failed: ${options.status}${options.body ? ` ${options.body}` : ""}`);
79
+ const server = options.status >= 500;
80
+ super(`GBP ${options.operation} failed: ${server ? "5xx" : options.status}`, server ? "api_5xx" : `api_${options.status}`, options.retryable, {
81
+ operation: options.operation,
82
+ status: options.status,
83
+ attempts: options.attempts,
84
+ ...options.body ? { body: options.body } : {},
85
+ ...options.retryAfterMs !== void 0 ? { retryAfterMs: options.retryAfterMs } : {}
86
+ });
87
+ this.body = options.body;
67
88
  this.operation = options.operation;
68
89
  this.status = options.status;
69
90
  this.retryable = options.retryable;
@@ -73,7 +94,7 @@ var GbpApiError = class extends GbpError {
73
94
  };
74
95
  var GbpTimeoutError = class extends GbpError {
75
96
  constructor(operation, timeoutMs) {
76
- super(`${operation} timed out after ${timeoutMs}ms`);
97
+ super(`GBP ${operation} timed out`, "timeout", true, { operation, timeoutMs });
77
98
  this.operation = operation;
78
99
  this.timeoutMs = timeoutMs;
79
100
  }
@@ -81,7 +102,10 @@ var GbpTimeoutError = class extends GbpError {
81
102
  var GbpPaginationError = class extends GbpError {
82
103
  constructor(reason, pagesFetched) {
83
104
  super(
84
- reason === "repeated_token" ? `Business Profile returned a page token it had already returned, after ${pagesFetched} page(s)` : `Business Profile reviews stopped at the page limit (${pagesFetched} pages)`
105
+ reason === "repeated_token" ? "GBP reviews.list returned a page token it had already returned" : "GBP reviews.list stopped at the page limit",
106
+ `pagination_${reason}`,
107
+ false,
108
+ { pagesFetched }
85
109
  );
86
110
  this.reason = reason;
87
111
  this.pagesFetched = pagesFetched;
@@ -92,8 +116,13 @@ function excerpt(text, max = 300) {
92
116
  return flat.length > max ? `${flat.slice(0, max)}...` : flat;
93
117
  }
94
118
  var GbpPublishedError = class extends GbpError {
95
- constructor(reason, client, detail, status) {
96
- super(`Published reviews for ${client} unusable (${reason}): ${detail}`);
119
+ constructor(reason, client, detail, status, body) {
120
+ const transient = reason === "http" && status !== void 0 && (status === 429 || status >= 500);
121
+ super(`Published reviews for ${client} unusable (${reason}): ${detail}`, `published_${reason}`, transient, {
122
+ client,
123
+ ...status !== void 0 ? { status } : {},
124
+ ...body ? { body } : {}
125
+ });
97
126
  this.reason = reason;
98
127
  this.client = client;
99
128
  this.status = status;
@@ -472,7 +501,8 @@ async function getPublishedReviews(options) {
472
501
  };
473
502
  const { response } = await fetchWithRetry(url, init, "reviews.published", resolvePolicy(request));
474
503
  if (!response.ok) {
475
- throw new GbpPublishedError("http", client, `${response.status} ${excerpt(await readErrorBody(response), 120)}`.trim(), response.status);
504
+ const body2 = excerpt(await readErrorBody(response), 120);
505
+ throw new GbpPublishedError("http", client, String(response.status), response.status, body2 || void 0);
476
506
  }
477
507
  let body;
478
508
  try {
@@ -485,10 +515,129 @@ async function getPublishedReviews(options) {
485
515
  if (limit !== void 0) reviews = reviews.slice(0, limit);
486
516
  return { ...result, reviews: order === "shuffle" ? shuffle(reviews) : reviews };
487
517
  }
518
+
519
+ // src/safe.ts
520
+ var DEFAULT_REPORT_INTERVAL_MS = 60 * 60 * 1e3;
521
+ var DEFAULT_MAX_STALE_MS = 30 * 24 * 60 * 60 * 1e3;
522
+ var DEFAULT_AUTH_RETRY_MS = 5 * 60 * 1e3;
523
+ var lastGood = /* @__PURE__ */ new Map();
524
+ var reported = /* @__PURE__ */ new Map();
525
+ var authBlockedUntil = /* @__PURE__ */ new Map();
526
+ function toGbpError(error) {
527
+ if (error instanceof GbpError) return error;
528
+ const name = error instanceof Error ? error.name : typeof error;
529
+ const message = error instanceof Error ? error.message : String(error);
530
+ const wrapped = new GbpError("GBP reviews failed: unexpected error", "unexpected", true, { name, message: excerpt(message, 200) });
531
+ wrapped.cause = error;
532
+ return wrapped;
533
+ }
534
+ function isAuthFailure(error) {
535
+ return error.code === "invalid_grant" || error.code === "invalid_client";
536
+ }
537
+ function defaultReport(error, report) {
538
+ console.warn(`[gbp] ${error.message}; serving ${report.served}`, report.context);
539
+ }
540
+ async function loadFallback(fallback) {
541
+ if (fallback === void 0) return null;
542
+ try {
543
+ return (typeof fallback === "function" ? await fallback() : fallback) ?? null;
544
+ } catch {
545
+ return null;
546
+ }
547
+ }
548
+ function reportOnce(error, served, options) {
549
+ const now = Date.now();
550
+ const window = options.reportIntervalMs ?? DEFAULT_REPORT_INTERVAL_MS;
551
+ const last = reported.get(error.code);
552
+ if (last && now - last.at < window) {
553
+ last.suppressed++;
554
+ return;
555
+ }
556
+ reported.set(error.code, { at: now, suppressed: 0 });
557
+ try {
558
+ (options.report ?? defaultReport)(error, {
559
+ fingerprint: error.fingerprint,
560
+ code: error.code,
561
+ transient: error.transient,
562
+ context: error.context,
563
+ served,
564
+ suppressed: last?.suppressed ?? 0
565
+ });
566
+ } catch {
567
+ }
568
+ }
569
+ async function safely(key, load, empty, options) {
570
+ const now = Date.now();
571
+ let error;
572
+ const blocked = authBlockedUntil.get(key);
573
+ if (blocked && blocked.until > now) {
574
+ error = blocked.error;
575
+ } else {
576
+ try {
577
+ const result = await load();
578
+ lastGood.set(key, { result, at: Date.now() });
579
+ authBlockedUntil.delete(key);
580
+ return { ...result, source: "live" };
581
+ } catch (caught) {
582
+ error = toGbpError(caught);
583
+ const retryMs = options.authRetryMs ?? DEFAULT_AUTH_RETRY_MS;
584
+ if (isAuthFailure(error) && retryMs > 0) authBlockedUntil.set(key, { until: Date.now() + retryMs, error });
585
+ }
586
+ }
587
+ let cached2 = lastGood.get(key);
588
+ if (cached2 && now - cached2.at > (options.maxStaleMs ?? DEFAULT_MAX_STALE_MS)) {
589
+ lastGood.delete(key);
590
+ cached2 = void 0;
591
+ }
592
+ let served;
593
+ if (cached2) {
594
+ served = { ...cached2.result, reviews: [...cached2.result.reviews], source: "cache", error };
595
+ } else {
596
+ const fallback = await loadFallback(options.fallback);
597
+ served = fallback ? { ...fallback, source: "fallback", error } : { ...empty(), error };
598
+ }
599
+ reportOnce(error, served.source, options);
600
+ return served;
601
+ }
602
+ function getBusinessReviewsSafe(options = {}) {
603
+ const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
604
+ const key = JSON.stringify([
605
+ "business",
606
+ process.env.GOOGLE_BUSINESS_LOCATION_ID ?? null,
607
+ fetchOptions.limit ?? null,
608
+ fetchOptions.filterMinStars ?? null,
609
+ fetchOptions.includeUnapprovedReplies ?? false
610
+ ]);
611
+ return safely(
612
+ key,
613
+ () => getBusinessReviews(fetchOptions),
614
+ () => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
615
+ { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
616
+ );
617
+ }
618
+ function getPublishedReviewsSafe(options) {
619
+ const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
620
+ const key = JSON.stringify([
621
+ "published",
622
+ fetchOptions.client,
623
+ fetchOptions.baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? null,
624
+ fetchOptions.limit ?? null,
625
+ fetchOptions.filterMinStars ?? null
626
+ ]);
627
+ return safely(
628
+ key,
629
+ () => getPublishedReviews(fetchOptions),
630
+ () => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
631
+ { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
632
+ );
633
+ }
488
634
  // Annotate the CommonJS export names for ESM import in node:
489
635
  0 && (module.exports = {
636
+ DEFAULT_AUTH_RETRY_MS,
490
637
  DEFAULT_MAX_PAGES,
638
+ DEFAULT_MAX_STALE_MS,
491
639
  DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
640
+ DEFAULT_REPORT_INTERVAL_MS,
492
641
  DEFAULT_REQUEST_POLICY,
493
642
  GbpApiError,
494
643
  GbpAuthError,
@@ -498,8 +647,10 @@ async function getPublishedReviews(options) {
498
647
  GbpTimeoutError,
499
648
  PUBLISHED_REVIEWS_SCHEMA,
500
649
  getBusinessReviews,
650
+ getBusinessReviewsSafe,
501
651
  getGoogleOAuthAccessToken,
502
652
  getPublishedReviews,
653
+ getPublishedReviewsSafe,
503
654
  hasGoogleOAuthCredentials,
504
655
  invalidateAccessToken,
505
656
  isBusinessProfileConfigured,
package/dist/index.d.cts CHANGED
@@ -287,13 +287,27 @@ declare function getPublishedReviews(options: GetPublishedReviewsOptions): Promi
287
287
  * "Google is having a bad minute" from "our code looped", and log the
288
288
  * difference without ever logging a credential.
289
289
  *
290
- * No message here carries a client id, client secret, refresh token, access
291
- * token or review text. Bodies from Google are cut to a short, bounded
292
- * excerpt.
290
+ * Every message is short and stable: the same failure always reads the same,
291
+ * so an error tracker groups it as one issue rather than one per response
292
+ * body. What varies (status, Google's description, a body excerpt) sits in
293
+ * `context`, and `fingerprint` is `["gbp", code]` for a tracker that takes
294
+ * one (Sentry's `captureException(error, { fingerprint, extra: context })`).
295
+ *
296
+ * No message or context here carries a client id, client secret, refresh
297
+ * token, access token or review text. Bodies from Google are cut to a short,
298
+ * bounded excerpt.
293
299
  */
294
300
  /** Base class: `instanceof GbpError` catches every failure this package throws. */
295
301
  declare class GbpError extends Error {
296
- constructor(message: string);
302
+ /** Short, stable name for the failure, e.g. `invalid_grant` or `api_5xx`. */
303
+ readonly code: string;
304
+ /** True when the same request may well work later without anyone changing anything. */
305
+ readonly transient: boolean;
306
+ /** What varies between occurrences: status, Google's description, a body excerpt. Never a credential. */
307
+ readonly context: Record<string, unknown>;
308
+ constructor(message: string, code?: string, transient?: boolean, context?: Record<string, unknown>);
309
+ /** `["gbp", code]`: one error-tracker issue per kind of failure. */
310
+ get fingerprint(): string[];
297
311
  }
298
312
  type GbpAuthErrorCode = "invalid_grant" | "invalid_client" | "token_request_failed";
299
313
  /**
@@ -319,6 +333,8 @@ declare class GbpApiError extends GbpError {
319
333
  readonly attempts: number;
320
334
  /** Set when Google asked for a longer wait than this package will sleep through. */
321
335
  readonly retryAfterMs?: number;
336
+ /** A short excerpt of Google's error body. In `context` too, never in the message. */
337
+ readonly body?: string;
322
338
  constructor(options: {
323
339
  operation: string;
324
340
  status: number;
@@ -354,7 +370,95 @@ declare class GbpPublishedError extends GbpError {
354
370
  readonly reason: "http" | "invalid" | "unknown_schema";
355
371
  readonly client: string;
356
372
  readonly status?: number;
357
- constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number);
373
+ constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number, body?: string);
374
+ }
375
+
376
+ /**
377
+ * Reviews that never throw into a page render.
378
+ *
379
+ * `getBusinessReviews` and `getPublishedReviews` throw on a real failure, so a
380
+ * caller can choose what to do. Most callers want the same thing: keep showing
381
+ * what they showed last time, tell someone once, and carry on. A dead refresh
382
+ * token (`invalid_grant`, every 7 days while the OAuth app is in Testing) or a
383
+ * Google 503 otherwise reaches every server render, and a site that reports
384
+ * each one sends thousands of events about one fault.
385
+ *
386
+ * The `...Safe` functions here catch every failure and serve, in order:
387
+ *
388
+ * 1. the last good result this process fetched for the same options
389
+ * (`source: "cache"`), up to `maxStaleMs` old;
390
+ * 2. the caller's `fallback`, a snapshot or a loader for one such as a KV
391
+ * read (`source: "fallback"`);
392
+ * 3. an empty result: no rating, no count, no reviews (`source: "empty"`).
393
+ *
394
+ * Each kind of failure is reported at most once per `reportIntervalMs` per
395
+ * process, through `report`. After an auth failure no amount of retrying
396
+ * helps, so the live call is skipped for `authRetryMs` and the cache or
397
+ * fallback is served straight away.
398
+ */
399
+
400
+ /** Where a safe result came from. */
401
+ type ReviewsSource = "live" | "cache" | "fallback" | "empty";
402
+ /** A result from a `...Safe` function: the reviews, where they came from, and the failure if there was one. */
403
+ type SafeReviewsResult<T extends BusinessReviewsResult = BusinessReviewsResult> = T & {
404
+ source: ReviewsSource;
405
+ /** The failure behind a `cache`, `fallback` or `empty` result. */
406
+ error?: GbpError;
407
+ };
408
+ /** What `report` is told alongside the error. */
409
+ interface GbpFailureReport {
410
+ /** `["gbp", code]`. Pass it to Sentry as `fingerprint` so every occurrence is one issue. */
411
+ fingerprint: string[];
412
+ code: string;
413
+ /** True when the failure may clear by itself (a 5xx, a timeout); false when a person has to act. */
414
+ transient: boolean;
415
+ /** Status, Google's description, a body excerpt. Never a credential. Pass it as Sentry `extra`. */
416
+ context: Record<string, unknown>;
417
+ /** What the page was given instead. */
418
+ served: Exclude<ReviewsSource, "live">;
419
+ /** Failures of this kind left unreported since the last report, because they fell inside the window. */
420
+ suppressed: number;
358
421
  }
422
+ interface SafeReviewsOptions<F extends BusinessReviewsResult = BusinessReviewsResult> {
423
+ /**
424
+ * Served when there is no cached result: a snapshot, or a function that loads one (a KV read, say). A loader that
425
+ * throws or returns nothing falls through to an empty result. Served as given: `limit` and `filterMinStars` are
426
+ * not applied to it.
427
+ */
428
+ fallback?: F | (() => F | null | undefined | Promise<F | null | undefined>);
429
+ /**
430
+ * Called at most once per kind of failure per `reportIntervalMs`, per process. Default: one `console.warn`.
431
+ *
432
+ * ```ts
433
+ * report: (error, { fingerprint, context, transient }) =>
434
+ * Sentry.captureException(error, { fingerprint, extra: context, level: transient ? "warning" : "error" }),
435
+ * ```
436
+ */
437
+ report?: (error: GbpError, report: GbpFailureReport) => void;
438
+ /** Default one hour. */
439
+ reportIntervalMs?: number;
440
+ /** Longest a cached result is served after the last good fetch. Default 30 days. */
441
+ maxStaleMs?: number;
442
+ /** After an auth failure, skip the live call for this long. Default five minutes. `0` turns it off. */
443
+ authRetryMs?: number;
444
+ }
445
+ declare const DEFAULT_REPORT_INTERVAL_MS: number;
446
+ declare const DEFAULT_MAX_STALE_MS: number;
447
+ declare const DEFAULT_AUTH_RETRY_MS: number;
448
+ /**
449
+ * `getBusinessReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
450
+ * empty result, and reports the failure at most once per window. `source` says which.
451
+ */
452
+ declare function getBusinessReviewsSafe(options?: GetBusinessReviewsOptions & SafeReviewsOptions): Promise<SafeReviewsResult>;
453
+ /** A published result, or a fallback or empty one, which carries no sync times. */
454
+ type SafePublishedReviewsResult = SafeReviewsResult<BusinessReviewsResult & Partial<Pick<PublishedReviewsResult, "syncedAt" | "publishedAt">>>;
455
+ /**
456
+ * `getPublishedReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
457
+ * empty result, and reports the failure at most once per window. `source` says which.
458
+ *
459
+ * Where a framework keeps the last good page when a render throws (a static build that refuses to publish), the
460
+ * throwing `getPublishedReviews` keeps that behaviour; use this where a throw would reach the visitor.
461
+ */
462
+ declare function getPublishedReviewsSafe(options: GetPublishedReviewsOptions & SafeReviewsOptions): Promise<SafePublishedReviewsResult>;
359
463
 
360
- export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_MAX_PAGES, DEFAULT_PUBLISHED_REVIEWS_BASE_URL, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, getBusinessReviews, getGoogleOAuthAccessToken, getPublishedReviews, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedReviews, parseRetryAfter, publishedReviewsUrl };
464
+ export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_AUTH_RETRY_MS, DEFAULT_MAX_PAGES, DEFAULT_MAX_STALE_MS, DEFAULT_PUBLISHED_REVIEWS_BASE_URL, DEFAULT_REPORT_INTERVAL_MS, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, type GbpFailureReport, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, type ReviewsSource, type SafePublishedReviewsResult, type SafeReviewsOptions, type SafeReviewsResult, getBusinessReviews, getBusinessReviewsSafe, getGoogleOAuthAccessToken, getPublishedReviews, getPublishedReviewsSafe, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedReviews, parseRetryAfter, publishedReviewsUrl };
package/dist/index.d.ts CHANGED
@@ -287,13 +287,27 @@ declare function getPublishedReviews(options: GetPublishedReviewsOptions): Promi
287
287
  * "Google is having a bad minute" from "our code looped", and log the
288
288
  * difference without ever logging a credential.
289
289
  *
290
- * No message here carries a client id, client secret, refresh token, access
291
- * token or review text. Bodies from Google are cut to a short, bounded
292
- * excerpt.
290
+ * Every message is short and stable: the same failure always reads the same,
291
+ * so an error tracker groups it as one issue rather than one per response
292
+ * body. What varies (status, Google's description, a body excerpt) sits in
293
+ * `context`, and `fingerprint` is `["gbp", code]` for a tracker that takes
294
+ * one (Sentry's `captureException(error, { fingerprint, extra: context })`).
295
+ *
296
+ * No message or context here carries a client id, client secret, refresh
297
+ * token, access token or review text. Bodies from Google are cut to a short,
298
+ * bounded excerpt.
293
299
  */
294
300
  /** Base class: `instanceof GbpError` catches every failure this package throws. */
295
301
  declare class GbpError extends Error {
296
- constructor(message: string);
302
+ /** Short, stable name for the failure, e.g. `invalid_grant` or `api_5xx`. */
303
+ readonly code: string;
304
+ /** True when the same request may well work later without anyone changing anything. */
305
+ readonly transient: boolean;
306
+ /** What varies between occurrences: status, Google's description, a body excerpt. Never a credential. */
307
+ readonly context: Record<string, unknown>;
308
+ constructor(message: string, code?: string, transient?: boolean, context?: Record<string, unknown>);
309
+ /** `["gbp", code]`: one error-tracker issue per kind of failure. */
310
+ get fingerprint(): string[];
297
311
  }
298
312
  type GbpAuthErrorCode = "invalid_grant" | "invalid_client" | "token_request_failed";
299
313
  /**
@@ -319,6 +333,8 @@ declare class GbpApiError extends GbpError {
319
333
  readonly attempts: number;
320
334
  /** Set when Google asked for a longer wait than this package will sleep through. */
321
335
  readonly retryAfterMs?: number;
336
+ /** A short excerpt of Google's error body. In `context` too, never in the message. */
337
+ readonly body?: string;
322
338
  constructor(options: {
323
339
  operation: string;
324
340
  status: number;
@@ -354,7 +370,95 @@ declare class GbpPublishedError extends GbpError {
354
370
  readonly reason: "http" | "invalid" | "unknown_schema";
355
371
  readonly client: string;
356
372
  readonly status?: number;
357
- constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number);
373
+ constructor(reason: "http" | "invalid" | "unknown_schema", client: string, detail: string, status?: number, body?: string);
374
+ }
375
+
376
+ /**
377
+ * Reviews that never throw into a page render.
378
+ *
379
+ * `getBusinessReviews` and `getPublishedReviews` throw on a real failure, so a
380
+ * caller can choose what to do. Most callers want the same thing: keep showing
381
+ * what they showed last time, tell someone once, and carry on. A dead refresh
382
+ * token (`invalid_grant`, every 7 days while the OAuth app is in Testing) or a
383
+ * Google 503 otherwise reaches every server render, and a site that reports
384
+ * each one sends thousands of events about one fault.
385
+ *
386
+ * The `...Safe` functions here catch every failure and serve, in order:
387
+ *
388
+ * 1. the last good result this process fetched for the same options
389
+ * (`source: "cache"`), up to `maxStaleMs` old;
390
+ * 2. the caller's `fallback`, a snapshot or a loader for one such as a KV
391
+ * read (`source: "fallback"`);
392
+ * 3. an empty result: no rating, no count, no reviews (`source: "empty"`).
393
+ *
394
+ * Each kind of failure is reported at most once per `reportIntervalMs` per
395
+ * process, through `report`. After an auth failure no amount of retrying
396
+ * helps, so the live call is skipped for `authRetryMs` and the cache or
397
+ * fallback is served straight away.
398
+ */
399
+
400
+ /** Where a safe result came from. */
401
+ type ReviewsSource = "live" | "cache" | "fallback" | "empty";
402
+ /** A result from a `...Safe` function: the reviews, where they came from, and the failure if there was one. */
403
+ type SafeReviewsResult<T extends BusinessReviewsResult = BusinessReviewsResult> = T & {
404
+ source: ReviewsSource;
405
+ /** The failure behind a `cache`, `fallback` or `empty` result. */
406
+ error?: GbpError;
407
+ };
408
+ /** What `report` is told alongside the error. */
409
+ interface GbpFailureReport {
410
+ /** `["gbp", code]`. Pass it to Sentry as `fingerprint` so every occurrence is one issue. */
411
+ fingerprint: string[];
412
+ code: string;
413
+ /** True when the failure may clear by itself (a 5xx, a timeout); false when a person has to act. */
414
+ transient: boolean;
415
+ /** Status, Google's description, a body excerpt. Never a credential. Pass it as Sentry `extra`. */
416
+ context: Record<string, unknown>;
417
+ /** What the page was given instead. */
418
+ served: Exclude<ReviewsSource, "live">;
419
+ /** Failures of this kind left unreported since the last report, because they fell inside the window. */
420
+ suppressed: number;
358
421
  }
422
+ interface SafeReviewsOptions<F extends BusinessReviewsResult = BusinessReviewsResult> {
423
+ /**
424
+ * Served when there is no cached result: a snapshot, or a function that loads one (a KV read, say). A loader that
425
+ * throws or returns nothing falls through to an empty result. Served as given: `limit` and `filterMinStars` are
426
+ * not applied to it.
427
+ */
428
+ fallback?: F | (() => F | null | undefined | Promise<F | null | undefined>);
429
+ /**
430
+ * Called at most once per kind of failure per `reportIntervalMs`, per process. Default: one `console.warn`.
431
+ *
432
+ * ```ts
433
+ * report: (error, { fingerprint, context, transient }) =>
434
+ * Sentry.captureException(error, { fingerprint, extra: context, level: transient ? "warning" : "error" }),
435
+ * ```
436
+ */
437
+ report?: (error: GbpError, report: GbpFailureReport) => void;
438
+ /** Default one hour. */
439
+ reportIntervalMs?: number;
440
+ /** Longest a cached result is served after the last good fetch. Default 30 days. */
441
+ maxStaleMs?: number;
442
+ /** After an auth failure, skip the live call for this long. Default five minutes. `0` turns it off. */
443
+ authRetryMs?: number;
444
+ }
445
+ declare const DEFAULT_REPORT_INTERVAL_MS: number;
446
+ declare const DEFAULT_MAX_STALE_MS: number;
447
+ declare const DEFAULT_AUTH_RETRY_MS: number;
448
+ /**
449
+ * `getBusinessReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
450
+ * empty result, and reports the failure at most once per window. `source` says which.
451
+ */
452
+ declare function getBusinessReviewsSafe(options?: GetBusinessReviewsOptions & SafeReviewsOptions): Promise<SafeReviewsResult>;
453
+ /** A published result, or a fallback or empty one, which carries no sync times. */
454
+ type SafePublishedReviewsResult = SafeReviewsResult<BusinessReviewsResult & Partial<Pick<PublishedReviewsResult, "syncedAt" | "publishedAt">>>;
455
+ /**
456
+ * `getPublishedReviews`, but it never throws: on any failure it serves the last good result, the `fallback`, or an
457
+ * empty result, and reports the failure at most once per window. `source` says which.
458
+ *
459
+ * Where a framework keeps the last good page when a render throws (a static build that refuses to publish), the
460
+ * throwing `getPublishedReviews` keeps that behaviour; use this where a throw would reach the visitor.
461
+ */
462
+ declare function getPublishedReviewsSafe(options: GetPublishedReviewsOptions & SafeReviewsOptions): Promise<SafePublishedReviewsResult>;
359
463
 
360
- export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_MAX_PAGES, DEFAULT_PUBLISHED_REVIEWS_BASE_URL, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, getBusinessReviews, getGoogleOAuthAccessToken, getPublishedReviews, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedReviews, parseRetryAfter, publishedReviewsUrl };
464
+ export { type AccessTokenOptions, type BusinessReview, type BusinessReviewsResult, DEFAULT_AUTH_RETRY_MS, DEFAULT_MAX_PAGES, DEFAULT_MAX_STALE_MS, DEFAULT_PUBLISHED_REVIEWS_BASE_URL, DEFAULT_REPORT_INTERVAL_MS, DEFAULT_REQUEST_POLICY, GbpApiError, GbpAuthError, type GbpAuthErrorCode, GbpError, type GbpFailureReport, GbpPaginationError, GbpPublishedError, type GbpRequestOptions, GbpTimeoutError, type GetBusinessReviewsOptions, type GetPublishedReviewsOptions, PUBLISHED_REVIEWS_SCHEMA, type PublishedReviewsResult, type ReviewMedia, type ReviewOrder, type ReviewReply, type ReviewReplyState, type ReviewsSource, type SafePublishedReviewsResult, type SafeReviewsOptions, type SafeReviewsResult, getBusinessReviews, getBusinessReviewsSafe, getGoogleOAuthAccessToken, getPublishedReviews, getPublishedReviewsSafe, hasGoogleOAuthCredentials, invalidateAccessToken, isBusinessProfileConfigured, parseListReviewsResponse, parsePublishedReviews, parseRetryAfter, publishedReviewsUrl };
package/dist/index.js CHANGED
@@ -1,16 +1,24 @@
1
1
  // src/errors.ts
2
2
  var GbpError = class extends Error {
3
- constructor(message) {
3
+ constructor(message, code = "unknown", transient = false, context = {}) {
4
4
  super(message);
5
5
  this.name = new.target.name;
6
+ this.code = code;
7
+ this.transient = transient;
8
+ this.context = context;
9
+ }
10
+ /** `["gbp", code]`: one error-tracker issue per kind of failure. */
11
+ get fingerprint() {
12
+ return ["gbp", this.code];
6
13
  }
7
14
  };
8
15
  var GbpAuthError = class extends GbpError {
9
16
  constructor(code, status, description) {
10
- super(
11
- `Google OAuth token refresh failed: ${status} ${code}${description ? ` (${description})` : ""}` + (code === "invalid_grant" ? ". Reauthorise the Google account and replace GBP_REFRESH_TOKEN." : "")
12
- );
13
- this.code = code;
17
+ super(`GBP token refresh failed: ${code}`, code, code === "token_request_failed" && status >= 500, {
18
+ status,
19
+ ...description ? { description } : {},
20
+ ...code === "invalid_grant" ? { remedy: "Reauthorise the Google account and replace GBP_REFRESH_TOKEN." } : {}
21
+ });
14
22
  this.reauthorizationRequired = code === "invalid_grant";
15
23
  this.status = status;
16
24
  this.description = description;
@@ -18,7 +26,15 @@ var GbpAuthError = class extends GbpError {
18
26
  };
19
27
  var GbpApiError = class extends GbpError {
20
28
  constructor(options) {
21
- super(`Business Profile reviews failed: ${options.status}${options.body ? ` ${options.body}` : ""}`);
29
+ const server = options.status >= 500;
30
+ super(`GBP ${options.operation} failed: ${server ? "5xx" : options.status}`, server ? "api_5xx" : `api_${options.status}`, options.retryable, {
31
+ operation: options.operation,
32
+ status: options.status,
33
+ attempts: options.attempts,
34
+ ...options.body ? { body: options.body } : {},
35
+ ...options.retryAfterMs !== void 0 ? { retryAfterMs: options.retryAfterMs } : {}
36
+ });
37
+ this.body = options.body;
22
38
  this.operation = options.operation;
23
39
  this.status = options.status;
24
40
  this.retryable = options.retryable;
@@ -28,7 +44,7 @@ var GbpApiError = class extends GbpError {
28
44
  };
29
45
  var GbpTimeoutError = class extends GbpError {
30
46
  constructor(operation, timeoutMs) {
31
- super(`${operation} timed out after ${timeoutMs}ms`);
47
+ super(`GBP ${operation} timed out`, "timeout", true, { operation, timeoutMs });
32
48
  this.operation = operation;
33
49
  this.timeoutMs = timeoutMs;
34
50
  }
@@ -36,7 +52,10 @@ var GbpTimeoutError = class extends GbpError {
36
52
  var GbpPaginationError = class extends GbpError {
37
53
  constructor(reason, pagesFetched) {
38
54
  super(
39
- reason === "repeated_token" ? `Business Profile returned a page token it had already returned, after ${pagesFetched} page(s)` : `Business Profile reviews stopped at the page limit (${pagesFetched} pages)`
55
+ reason === "repeated_token" ? "GBP reviews.list returned a page token it had already returned" : "GBP reviews.list stopped at the page limit",
56
+ `pagination_${reason}`,
57
+ false,
58
+ { pagesFetched }
40
59
  );
41
60
  this.reason = reason;
42
61
  this.pagesFetched = pagesFetched;
@@ -47,8 +66,13 @@ function excerpt(text, max = 300) {
47
66
  return flat.length > max ? `${flat.slice(0, max)}...` : flat;
48
67
  }
49
68
  var GbpPublishedError = class extends GbpError {
50
- constructor(reason, client, detail, status) {
51
- super(`Published reviews for ${client} unusable (${reason}): ${detail}`);
69
+ constructor(reason, client, detail, status, body) {
70
+ const transient = reason === "http" && status !== void 0 && (status === 429 || status >= 500);
71
+ super(`Published reviews for ${client} unusable (${reason}): ${detail}`, `published_${reason}`, transient, {
72
+ client,
73
+ ...status !== void 0 ? { status } : {},
74
+ ...body ? { body } : {}
75
+ });
52
76
  this.reason = reason;
53
77
  this.client = client;
54
78
  this.status = status;
@@ -427,7 +451,8 @@ async function getPublishedReviews(options) {
427
451
  };
428
452
  const { response } = await fetchWithRetry(url, init, "reviews.published", resolvePolicy(request));
429
453
  if (!response.ok) {
430
- throw new GbpPublishedError("http", client, `${response.status} ${excerpt(await readErrorBody(response), 120)}`.trim(), response.status);
454
+ const body2 = excerpt(await readErrorBody(response), 120);
455
+ throw new GbpPublishedError("http", client, String(response.status), response.status, body2 || void 0);
431
456
  }
432
457
  let body;
433
458
  try {
@@ -440,9 +465,128 @@ async function getPublishedReviews(options) {
440
465
  if (limit !== void 0) reviews = reviews.slice(0, limit);
441
466
  return { ...result, reviews: order === "shuffle" ? shuffle(reviews) : reviews };
442
467
  }
468
+
469
+ // src/safe.ts
470
+ var DEFAULT_REPORT_INTERVAL_MS = 60 * 60 * 1e3;
471
+ var DEFAULT_MAX_STALE_MS = 30 * 24 * 60 * 60 * 1e3;
472
+ var DEFAULT_AUTH_RETRY_MS = 5 * 60 * 1e3;
473
+ var lastGood = /* @__PURE__ */ new Map();
474
+ var reported = /* @__PURE__ */ new Map();
475
+ var authBlockedUntil = /* @__PURE__ */ new Map();
476
+ function toGbpError(error) {
477
+ if (error instanceof GbpError) return error;
478
+ const name = error instanceof Error ? error.name : typeof error;
479
+ const message = error instanceof Error ? error.message : String(error);
480
+ const wrapped = new GbpError("GBP reviews failed: unexpected error", "unexpected", true, { name, message: excerpt(message, 200) });
481
+ wrapped.cause = error;
482
+ return wrapped;
483
+ }
484
+ function isAuthFailure(error) {
485
+ return error.code === "invalid_grant" || error.code === "invalid_client";
486
+ }
487
+ function defaultReport(error, report) {
488
+ console.warn(`[gbp] ${error.message}; serving ${report.served}`, report.context);
489
+ }
490
+ async function loadFallback(fallback) {
491
+ if (fallback === void 0) return null;
492
+ try {
493
+ return (typeof fallback === "function" ? await fallback() : fallback) ?? null;
494
+ } catch {
495
+ return null;
496
+ }
497
+ }
498
+ function reportOnce(error, served, options) {
499
+ const now = Date.now();
500
+ const window = options.reportIntervalMs ?? DEFAULT_REPORT_INTERVAL_MS;
501
+ const last = reported.get(error.code);
502
+ if (last && now - last.at < window) {
503
+ last.suppressed++;
504
+ return;
505
+ }
506
+ reported.set(error.code, { at: now, suppressed: 0 });
507
+ try {
508
+ (options.report ?? defaultReport)(error, {
509
+ fingerprint: error.fingerprint,
510
+ code: error.code,
511
+ transient: error.transient,
512
+ context: error.context,
513
+ served,
514
+ suppressed: last?.suppressed ?? 0
515
+ });
516
+ } catch {
517
+ }
518
+ }
519
+ async function safely(key, load, empty, options) {
520
+ const now = Date.now();
521
+ let error;
522
+ const blocked = authBlockedUntil.get(key);
523
+ if (blocked && blocked.until > now) {
524
+ error = blocked.error;
525
+ } else {
526
+ try {
527
+ const result = await load();
528
+ lastGood.set(key, { result, at: Date.now() });
529
+ authBlockedUntil.delete(key);
530
+ return { ...result, source: "live" };
531
+ } catch (caught) {
532
+ error = toGbpError(caught);
533
+ const retryMs = options.authRetryMs ?? DEFAULT_AUTH_RETRY_MS;
534
+ if (isAuthFailure(error) && retryMs > 0) authBlockedUntil.set(key, { until: Date.now() + retryMs, error });
535
+ }
536
+ }
537
+ let cached2 = lastGood.get(key);
538
+ if (cached2 && now - cached2.at > (options.maxStaleMs ?? DEFAULT_MAX_STALE_MS)) {
539
+ lastGood.delete(key);
540
+ cached2 = void 0;
541
+ }
542
+ let served;
543
+ if (cached2) {
544
+ served = { ...cached2.result, reviews: [...cached2.result.reviews], source: "cache", error };
545
+ } else {
546
+ const fallback = await loadFallback(options.fallback);
547
+ served = fallback ? { ...fallback, source: "fallback", error } : { ...empty(), error };
548
+ }
549
+ reportOnce(error, served.source, options);
550
+ return served;
551
+ }
552
+ function getBusinessReviewsSafe(options = {}) {
553
+ const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
554
+ const key = JSON.stringify([
555
+ "business",
556
+ process.env.GOOGLE_BUSINESS_LOCATION_ID ?? null,
557
+ fetchOptions.limit ?? null,
558
+ fetchOptions.filterMinStars ?? null,
559
+ fetchOptions.includeUnapprovedReplies ?? false
560
+ ]);
561
+ return safely(
562
+ key,
563
+ () => getBusinessReviews(fetchOptions),
564
+ () => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
565
+ { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
566
+ );
567
+ }
568
+ function getPublishedReviewsSafe(options) {
569
+ const { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs, ...fetchOptions } = options;
570
+ const key = JSON.stringify([
571
+ "published",
572
+ fetchOptions.client,
573
+ fetchOptions.baseUrl ?? process.env.GBP_REVIEWS_BASE_URL ?? null,
574
+ fetchOptions.limit ?? null,
575
+ fetchOptions.filterMinStars ?? null
576
+ ]);
577
+ return safely(
578
+ key,
579
+ () => getPublishedReviews(fetchOptions),
580
+ () => ({ averageRating: null, totalReviewCount: null, reviews: [], source: "empty" }),
581
+ { fallback, report, reportIntervalMs, maxStaleMs, authRetryMs }
582
+ );
583
+ }
443
584
  export {
585
+ DEFAULT_AUTH_RETRY_MS,
444
586
  DEFAULT_MAX_PAGES,
587
+ DEFAULT_MAX_STALE_MS,
445
588
  DEFAULT_PUBLISHED_REVIEWS_BASE_URL,
589
+ DEFAULT_REPORT_INTERVAL_MS,
446
590
  DEFAULT_REQUEST_POLICY,
447
591
  GbpApiError,
448
592
  GbpAuthError,
@@ -452,8 +596,10 @@ export {
452
596
  GbpTimeoutError,
453
597
  PUBLISHED_REVIEWS_SCHEMA,
454
598
  getBusinessReviews,
599
+ getBusinessReviewsSafe,
455
600
  getGoogleOAuthAccessToken,
456
601
  getPublishedReviews,
602
+ getPublishedReviewsSafe,
457
603
  hasGoogleOAuthCredentials,
458
604
  invalidateAccessToken,
459
605
  isBusinessProfileConfigured,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@domandigital/gbp",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "Zero-dependency Google Business Profile client: OAuth refresh-token auth and a reviews (with owner replies) fetch, shared across Doman Digital / IDS client sites.",
6
6
  "license": "Apache-2.0",