188 lines
11 KiB
TypeScript
188 lines
11 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, 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 || !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);
|
|
},
|
|
};
|
|
}
|