storebound/cli/play/client.ts

194 lines
12 KiB
TypeScript

// Google Play Developer API (androidpublisher v3) client: JSON + media calls with retry, and the edit lifecycle.
import { PLAY_SCOPE, playToken } from '../lib/google-auth';
import { accessError } from '../lib/google-access';
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, isAutoSendOnly, 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<ReturnType<typeof playClient>>;
export async function playClient(root: string, packageName: string) {
// the credential broker's token when one is configured, else the local service account key (cli/lib/google-auth.ts)
const token = playToken(root, PLAY_SCOPE, { packageName });
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<T = unknown>(method: string, url: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
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*</.test(text) ? `(HTML ${r.status} page: wrong path or casing?)` : errorBody(text);
if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${method} ${short()} -> ${r.status} ${msg}`, r.status);
if (r.status === 404 && method === 'DELETE' && attempt > 0) return {} as T;
// a disabled API or a missing Play permission: the exact API / grant and its link, never retried (pitfall 122)
if (r.status === 403 || r.status === 401) { const g = await accessError(`${method} ${short()} -> ${r.status} ${msg}`, { app: packageName, root, source: token.source() }); if (g) throw g; }
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<string> {
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<void> {
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<T>(fn: (id: string, ctl: EditCtl) => Promise<T>, o: EditOpts): Promise<T> {
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<void> => {
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 && isAutoSendOnly(err)) {
notSent = false;
log('play', 'Play answered "Changes are sent for review automatically" (managed publishing is off): committing again without changesNotSentForReview; Play sends the changes for review itself');
await commit();
} else {
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 = <T>(what: string, fn: () => Promise<T>, timeoutMs: number): Promise<T> =>
withMachineLock(`play-edit-${packageName}`, `play ${what} ${packageName}`, fn, { timeoutMs, onWait: m => log('play', m) });
return {
API, UPLOAD, packageName, call,
/** Where this client's tokens come from: "broker" or "file" (no values). */
credentialSource: () => token.source(),
/**
* 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<T>(fn: (id: string) => Promise<T>): Promise<T> {
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<T>(fn: (id: string, ctl: EditCtl) => Promise<T>, o: EditOpts & { retries?: number } = {}): Promise<T> {
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);
},
};
}