@steve31415/baselib 3.56.0 → 3.57.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/README.md CHANGED
@@ -49,7 +49,8 @@ job does not fast-forward the checkout under the build.
49
49
  `twitter` provides Mirror-first tweet lookup, TwitterAPI.io batch/search/
50
50
  article calls, and thread assembly through direct reply edges. It includes
51
51
  one level of quotes, article bodies and URL expansions. Use one shared
52
- `MirrorTweetClient(mirrorUrl, serviceFetch)` and `TwitterApiIoClient(key)`
52
+ `MirrorTweetClient(mirrorUrl, serviceFetch, { logger })` (the logger gets
53
+ the WARN when a refused query is retried) and `TwitterApiIoClient(key)`
53
54
  per run; `assembleThread(id, { api, mirror, ancestors: 'all' })` returns
54
55
  role-tagged tweets (with `links`: expanded URLs and link-card text), source
55
56
  counts and warnings; add `replies: { rule: 'digest', followed }` for Digest's
@@ -0,0 +1,28 @@
1
+ import type { Logger } from './log-core.js';
2
+ /** The statuses retried. A 500 page is left out: Cloud Run also sends one
3
+ * when the app crashes mid-request, and that is not worth a second try. */
4
+ export declare const FRONT_END_REFUSAL_STATUSES: ReadonlySet<number>;
5
+ /** Pause before the one retry. Each attempt keeps whatever timeout `send`
6
+ * gives it, so a caller's worst case is two of its timeouts plus this. */
7
+ export declare const FRONT_END_RETRY_DELAY_MS = 1000;
8
+ /** True when `status` and `body` are Google's front end refusing the request
9
+ * (a 502/503/504 HTML page), not the app answering. */
10
+ export declare function isFrontEndRefusal(status: number, body: string): boolean;
11
+ export interface FrontEndRetryOptions {
12
+ logger: Pick<Logger, 'warn'>;
13
+ /** The WARN logged before the retry, e.g. 'mirror_query_retry'. */
14
+ event: string;
15
+ /** Extra fields for that WARN (the caller's label, path, page). It adds
16
+ * status, body (first 200 chars) and elapsedMs of the refused attempt. */
17
+ meta?: Record<string, unknown>;
18
+ /** Default FRONT_END_RETRY_DELAY_MS. */
19
+ delayMs?: number;
20
+ }
21
+ /** Send one idempotent request; when Google's front end refuses it
22
+ * (isFrontEndRefusal), log a WARN and send it once more after the delay.
23
+ * `send` builds the request afresh each time, timeout signal included.
24
+ * Returns the last Response as `send` produced it: any failure other than a
25
+ * first refusal, and a second refusal, come back with the body unread for
26
+ * the caller's own ERROR path. A thrown error (network, timeout) is not
27
+ * retried here. */
28
+ export declare function withFrontEndRetry(send: () => Promise<Response>, opts: FrontEndRetryOptions): Promise<Response>;
@@ -0,0 +1,50 @@
1
+ // One retry for a request Google's front end refused without passing it to
2
+ // the app (PW KNOWN_LOG_PATTERNS.md, "Cloud Run's front end answers a 5xx
3
+ // while the instance stays up"). The load balancer or Cloud Run's serving
4
+ // front end answers with its own HTML error page and the target's Cloud Run
5
+ // request log has no entry: 2026-11-06 12:00:01Z, Digest's Mirror tweet
6
+ // query got a 502 page after 8.93 s and its whole sync failed at ERROR. The
7
+ // LB re-sends a bodiless request on a 502 itself, so it is mostly POSTs with
8
+ // a body (Mirror's record queries) that surface the refusal.
9
+ //
10
+ // Only for reads and other idempotent requests. That a refused request never
11
+ // reached the app is inferred from the missing Cloud Run entry, not
12
+ // guaranteed, so a write that must not run twice is never sent through here.
13
+ /** The statuses retried. A 500 page is left out: Cloud Run also sends one
14
+ * when the app crashes mid-request, and that is not worth a second try. */
15
+ export const FRONT_END_REFUSAL_STATUSES = new Set([502, 503, 504]);
16
+ /** Google's error page; an app's own error is JSON or plain text. */
17
+ const GOOGLE_ERROR_PAGE = /^\s*(<!doctype html|<html)/i;
18
+ /** Pause before the one retry. Each attempt keeps whatever timeout `send`
19
+ * gives it, so a caller's worst case is two of its timeouts plus this. */
20
+ export const FRONT_END_RETRY_DELAY_MS = 1_000;
21
+ /** True when `status` and `body` are Google's front end refusing the request
22
+ * (a 502/503/504 HTML page), not the app answering. */
23
+ export function isFrontEndRefusal(status, body) {
24
+ return FRONT_END_REFUSAL_STATUSES.has(status) && GOOGLE_ERROR_PAGE.test(body);
25
+ }
26
+ /** Send one idempotent request; when Google's front end refuses it
27
+ * (isFrontEndRefusal), log a WARN and send it once more after the delay.
28
+ * `send` builds the request afresh each time, timeout signal included.
29
+ * Returns the last Response as `send` produced it: any failure other than a
30
+ * first refusal, and a second refusal, come back with the body unread for
31
+ * the caller's own ERROR path. A thrown error (network, timeout) is not
32
+ * retried here. */
33
+ export async function withFrontEndRetry(send, opts) {
34
+ const startedAt = Date.now();
35
+ const first = await send();
36
+ if (!FRONT_END_REFUSAL_STATUSES.has(first.status))
37
+ return first;
38
+ const body = await first.clone().text().catch(() => '');
39
+ if (!isFrontEndRefusal(first.status, body))
40
+ return first;
41
+ await first.body?.cancel().catch(() => { });
42
+ opts.logger.warn(opts.event, {
43
+ ...opts.meta,
44
+ status: first.status,
45
+ body: body.slice(0, 200),
46
+ elapsedMs: Date.now() - startedAt,
47
+ });
48
+ await new Promise((resolve) => setTimeout(resolve, opts.delayMs ?? FRONT_END_RETRY_DELAY_MS));
49
+ return send();
50
+ }
package/dist/s2s.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { OAuth2Client } from 'google-auth-library';
2
2
  import { type Logger } from './log-core.js';
3
+ export * from './front-end-retry.js';
3
4
  /** The fleet coding agent's service-account email, read from AGENT_SA — set at
4
5
  * deploy time by each service (SECURITY.md: agents have full access, so
5
6
  * grantAllows always admits this identity). Env-driven rather than a baked-in
package/dist/s2s.js CHANGED
@@ -18,6 +18,8 @@ import { createHash } from 'node:crypto';
18
18
  import { authMode } from './config.js';
19
19
  import { truncate } from './log-core.js';
20
20
  import { fleetFetch } from './fleet-dns.js';
21
+ // The one-retry wrapper for a caller's idempotent serviceFetch requests.
22
+ export * from './front-end-retry.js';
21
23
  /** The fleet coding agent's service-account email, read from AGENT_SA — set at
22
24
  * deploy time by each service (SECURITY.md: agents have full access, so
23
25
  * grantAllows always admits this identity). Env-driven rather than a baked-in
@@ -1,4 +1,5 @@
1
1
  import type { serviceFetch } from './s2s.js';
2
+ import type { Logger } from './log-core.js';
2
3
  import { type TwitterApiIoTweet, type TweetEntityUrl } from './twitter-client.js';
3
4
  export interface MirrorTweetRecord {
4
5
  tweet_id: string;
@@ -36,13 +37,19 @@ export declare const MIRROR_CONVERSATION_MAX_PAGES = 10;
36
37
  /** Shared per run: batch prefetch seeds the id cache, conversation requests
37
38
  * are coalesced, and failed requests are evicted so a later link can retry.
38
39
  * Conversations follow Mirror's keyset cursor (record_timestamp DESC, id
39
- * DESC) while `has_more`, bounded by MIRROR_CONVERSATION_MAX_PAGES. */
40
+ * DESC) while `has_more`, bounded by MIRROR_CONVERSATION_MAX_PAGES.
41
+ * A query Google's front end refuses is sent once more (withFrontEndRetry:
42
+ * every query is a read); pass `logger` so the retry logs its WARN
43
+ * (`mirror_tweets_query_retry`). */
40
44
  export declare class MirrorTweetClient implements MirrorTwitterClient {
41
45
  private readonly mirrorUrl;
42
46
  private readonly fetchFn;
47
+ private readonly opts;
43
48
  private readonly byId;
44
49
  private readonly conversations;
45
- constructor(mirrorUrl: string, fetchFn: typeof serviceFetch);
50
+ constructor(mirrorUrl: string, fetchFn: typeof serviceFetch, opts?: {
51
+ logger?: Pick<Logger, 'warn'>;
52
+ });
46
53
  private query;
47
54
  getTweetsByIds(ids: string[]): Promise<TwitterApiIoTweet[]>;
48
55
  getConversation(id: string): Promise<MirrorConversation>;
@@ -1,3 +1,4 @@
1
+ import { withFrontEndRetry } from './front-end-retry.js';
1
2
  import { assertTweetId } from './twitter-client.js';
2
3
  /** Mirror snapshots store URL expansions under `url`, without the original
3
4
  * t.co token. Keep both shapes; rendering handles the positional fallback. */
@@ -37,23 +38,30 @@ export const MIRROR_CONVERSATION_MAX_PAGES = 10;
37
38
  /** Shared per run: batch prefetch seeds the id cache, conversation requests
38
39
  * are coalesced, and failed requests are evicted so a later link can retry.
39
40
  * Conversations follow Mirror's keyset cursor (record_timestamp DESC, id
40
- * DESC) while `has_more`, bounded by MIRROR_CONVERSATION_MAX_PAGES. */
41
+ * DESC) while `has_more`, bounded by MIRROR_CONVERSATION_MAX_PAGES.
42
+ * A query Google's front end refuses is sent once more (withFrontEndRetry:
43
+ * every query is a read); pass `logger` so the retry logs its WARN
44
+ * (`mirror_tweets_query_retry`). */
41
45
  export class MirrorTweetClient {
42
46
  mirrorUrl;
43
47
  fetchFn;
48
+ opts;
44
49
  byId = new Map();
45
50
  conversations = new Map();
46
- constructor(mirrorUrl, fetchFn) {
51
+ constructor(mirrorUrl, fetchFn, opts = {}) {
47
52
  this.mirrorUrl = mirrorUrl;
48
53
  this.fetchFn = fetchFn;
54
+ this.opts = opts;
49
55
  }
50
56
  async query(filter, cursor) {
51
- const response = await this.fetchFn(`${this.mirrorUrl.replace(/\/$/, '')}/api/records/tweets/query`, {
52
- audience: this.mirrorUrl.replace(/\/$/, ''), method: 'POST',
57
+ const base = this.mirrorUrl.replace(/\/$/, '');
58
+ const body = JSON.stringify({ filter_sql: filter, columns: '*', limit: MIRROR_PAGE_LIMIT, ...(cursor ? { cursor } : {}) });
59
+ const response = await withFrontEndRetry(() => this.fetchFn(`${base}/api/records/tweets/query`, {
60
+ audience: base, method: 'POST',
53
61
  headers: { 'content-type': 'application/json' },
54
- body: JSON.stringify({ filter_sql: filter, columns: '*', limit: MIRROR_PAGE_LIMIT, ...(cursor ? { cursor } : {}) }),
62
+ body,
55
63
  signal: AbortSignal.timeout(30_000),
56
- });
64
+ }), { logger: this.opts.logger ?? { warn: () => { } }, event: 'mirror_tweets_query_retry', meta: { filter: filter.slice(0, 200), cursor: cursor ?? null } });
57
65
  if (!response.ok)
58
66
  throw new Error(`Mirror tweets query HTTP ${response.status}`);
59
67
  const data = await response.json();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.56.0",
3
+ "version": "3.57.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",