// Google Play Developer API (androidpublisher v3) client: JSON + media calls with retry, and the edit lifecycle. import { playServiceAccount } from '../lib/credentials'; import { tokenSource } from '../lib/google-auth'; import { log, warn } from '../lib/log'; import { errorBody } from '../lib/http'; import { noteGoogle } from '../lib/quota'; import { AmbiguousWriteError, isAmbiguous, retrySafe } from '../lib/upsert'; import * as rt from '../lib/rt'; import { withMachineLock } from '../lib/lock'; import { clearPending, isReviewRefused, notSentMessage, publishingUrl, recordPending, withNotSent } from './review-pending'; /** A read's edit is gone: replaced by a newer edit of the app, committed elsewhere or expired. */ export const EDIT_GONE = /Edit has been deleted|editAlreadyCommitted|already been committed|edit.*(expired|not found)|outside of this Edit/i; export type PlayClient = Awaited>; export async function playClient(root: string, packageName: string) { const token = tokenSource(playServiceAccount(root)); const API = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}`; const UPLOAD = `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${packageName}`; /** * JSON call (object body) or media upload (Uint8Array body + Content-Type). Throws with the API's text. 429 is * retried (refused, not applied); 5xx and network errors only for requests that end the same when sent twice * (retrySafe). A create or upload POST then throws AmbiguousWriteError instead of being sent again. */ async function call(method: string, url: string, body?: unknown, headers: Record = {}): Promise { const bin = body instanceof Uint8Array; const safe = retrySafe(method, url); const short = (): string => url.replace(API, '').replace(UPLOAD, ''); for (let attempt = 0; ; attempt++) { let r: Response; try { r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined || bin ? {} : { 'Content-Type': 'application/json' }), ...headers }, body: body === undefined ? undefined : bin ? (body as unknown as BodyInit) : JSON.stringify(body), }); } catch (e) { if (safe && attempt < 4) { await rt.sleep(2000 * (attempt + 1)); continue; } if (!safe) throw new AmbiguousWriteError(`${method} ${short()} -> no answer (${(e as Error).message})`); throw e; } noteGoogle(r.status); const text = await r.text(); if (r.ok) return (text ? JSON.parse(text) : {}) as T; if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 4) { await rt.sleep(2000 * (attempt + 1)); continue; } // A wrong path casing answers with an HTML 404 page, not JSON: say so instead of dumping the HTML. const msg = /^\s*= 500 && !safe) throw new AmbiguousWriteError(`${method} ${short()} -> ${r.status} ${msg}`, r.status); if (r.status === 404 && method === 'DELETE' && attempt > 0) return {} as T; throw new Error(`${method} ${short()} -> ${r.status} ${msg}`); } } /** At most one open edit per run: a second one would be invalidated by the first commit (or invalidate it). */ let openEdit: string | null = null; async function openOne(): Promise { if (openEdit) throw new Error(`a Play edit (${openEdit}) is already open in this run: storebound never opens a second one`); const e = await call<{ id: string }>('POST', `${API}/edits`, {}); openEdit = e.id; return e.id; } async function closeOne(id: string, what: string): Promise { try { await call('DELETE', `${API}/edits/${id}`); } catch (err) { // an edit that was committed or expired is gone already; anything else is reported (Play drops it later) if (!/-> 404|not found|has been deleted|already been committed/i.test(String(err))) warn('play', `${what} edit ${id} not deleted: ${(err as Error).message.slice(0, 200)}`); } finally { if (openEdit === id) openEdit = null; } } /** Thrown when a commit got no clear answer: the edit may be live, so it is never redone. */ class CommitUnknown extends Error { readonly noRedo = true; } /** Controls of an edit: `unchanged()` when fn found nothing to change; the edit is then deleted, not committed. */ interface EditCtl { unchanged(): void } /** Edit options. `notSentForReview` commits with changesNotSentForReview=true from the start (pitfall 72); * `summary` names the change in the local record of such a commit (play status). */ interface EditOpts { dry?: boolean; commitQuery?: string; notSentForReview?: boolean; summary?: string } async function editOnce(fn: (id: string, ctl: EditCtl) => Promise, o: EditOpts): Promise { const id = await openOne(); log('play', `edit ${id} opened`); let same = false; let committing = false; let notSent = !!o.notSentForReview; const refused = (): void => { notSent = true; log('play', 'Play refuses to send these changes for review automatically (after a rejection, other pending changes or managed publishing): committing with changesNotSentForReview=true (only edits.commit takes it; validate answers 400 "Unknown name")'); }; try { const out = await fn(id, { unchanged: () => { same = true; } }); if (o.dry) { // validate never takes changesNotSentForReview (live: 400 "Unknown name changesNotSentForReview: Cannot bind // query parameter"); its refusal only tells the dry run how the real run will commit try { await call('POST', `${API}/edits/${id}:validate`); } catch (err) { if (!isReviewRefused(err)) throw err; refused(); } log('play', 'dry run: edit validated, discarding'); if (notSent) log('play', `dry run: the real run commits with changesNotSentForReview=true; Play then does not send it for review: Send changes for review in Publishing overview is the owner's button: ${publishingUrl()}`); await closeOne(id, 'dry run'); } else if (same) { log('play', 'nothing to change: edit discarded, not committed'); await closeOne(id, 'unchanged'); } else { committing = true; // a commit refused with a 400 leaves the edit open and applies nothing: the same edit id is validated and // committed once more with the parameter (no other request is repeated) const commit = async (): Promise => { try { await call('POST', `${API}/edits/${id}:commit${withNotSent(o.commitQuery, notSent)}`); } catch (err) { if (!isAmbiguous(err)) throw err; openEdit = null; throw new CommitUnknown(`the commit of edit ${id} got no clear answer (${(err as Error).message.slice(0, 200)}): it may be live. Not redone, so nothing is applied twice; check with \`storebound play status\` (or the dry run of this command) before running it again.`); } }; try { await commit(); } catch (err) { if (notSent || !isReviewRefused(err)) throw err; refused(); await commit(); } openEdit = null; log('play', `edit ${id} committed${notSent ? ' with changesNotSentForReview=true' : ''}`); if (notSent) { recordPending(root, { packageName, editId: id, committedAt: new Date().toISOString(), summary: o.summary ?? 'play edit', forced: !!o.notSentForReview }); warn('play', notSentMessage()); } else clearPending(root, packageName); } return out; } catch (err) { // nothing of an uncommitted edit is live: delete it (also after a failed commit, which leaves the edit open) if (!(err instanceof CommitUnknown)) await closeOne(id, committing ? 'failed commit' : 'failed'); throw err; } } /** Google keeps one open edit per app; every edit (read or write) of this package, in any storebound process on * this machine, runs under one lock (cli/lib/lock.ts), so parallel reads never delete each other's edits. */ const locked = (what: string, fn: () => Promise, timeoutMs: number): Promise => withMachineLock(`play-edit-${packageName}`, `play ${what} ${packageName}`, fn, { timeoutMs, onWait: m => log('play', m) }); return { API, UPLOAD, packageName, call, /** * Read-only edit: opens one, runs fn (GETs only), then deletes it, also on error. Never validated or committed. * A failed delete is reported (Play drops an unused edit on its own after a while, but it should not linger). */ async read(fn: (id: string) => Promise): Promise { return locked('read', async () => { for (let attempt = 0; ; attempt++) { const id = await openOne(); try { return await fn(id); } catch (err) { // the edit was replaced by another writer (Console, another tool) or expired: a read is safe to redo once if (attempt === 0 && EDIT_GONE.test(String(err))) { log('play', `read edit ${id} was invalidated, reading again with a new edit`); continue; } throw err; } finally { await closeOne(id, 'read-only'); } } }, 90_000); }, /** * Opens an edit, runs fn, then commits (or validates and discards when dry). Any Console save or other edit commit * invalidates an open edit ("A change was made to the application outside of this Edit"); then the whole edit is * redone, up to `retries` times. Keep edits small (e.g. one per language for images) so a retry is cheap. * Play's 400 "Changes cannot be sent for review automatically" (pitfall 72) is answered on the same edit: validate * and commit again with changesNotSentForReview=true (a dry run says so; validate never takes the parameter); the owner then * presses Send changes for review in Publishing overview, recorded in .storebound/ for `play status`. */ async edit(fn: (id: string, ctl: EditCtl) => Promise, o: EditOpts & { retries?: number } = {}): Promise { return locked(o.dry ? 'dry edit' : 'edit', async () => { for (let left = o.retries ?? 3; ; left--) { try { return await editOnce(fn, o); } catch (err) { // redo only what never went live: an edit invalidated before or at its commit, or a request inside the edit // without a clear answer (the edit was deleted, so nothing of it applies). Never after an unclear commit. if (left <= 0 || (err as { noRedo?: boolean }).noRedo) throw err; const outside = /outside of this Edit|Edit has been deleted|edit.*(expired|not found)/i.test(String(err)); if (!outside && !isAmbiguous(err)) throw err; log('play', `${outside ? 'app changed outside the edit (Console?)' : 'a request got no clear answer; the edit was discarded'}, redoing it (${left} retries left)`); } } }, 10 * 60_000); }, }; }