storebound/cli/play/client.ts

132 lines
7.3 KiB
TypeScript

// 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';
export type PlayClient = Awaited<ReturnType<typeof playClient>>;
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<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;
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 }
async function editOnce<T>(fn: (id: string, ctl: EditCtl) => Promise<T>, o: { dry?: boolean; commitQuery?: string }): Promise<T> {
const id = await openOne();
log('play', `edit ${id} opened`);
let same = false;
let committing = false;
try {
const out = await fn(id, { unchanged: () => { same = true; } });
if (o.dry) {
await call('POST', `${API}/edits/${id}:validate`);
log('play', 'dry run: edit validated, discarding');
await closeOne(id, 'dry run');
} else if (same) {
log('play', 'nothing to change: edit discarded, not committed');
await closeOne(id, 'unchanged');
} else {
committing = true;
try { await call('POST', `${API}/edits/${id}:commit${o.commitQuery ?? ''}`); } 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.`);
}
openEdit = null;
log('play', `edit ${id} committed`);
}
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;
}
}
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<T>(fn: (id: string) => Promise<T>): Promise<T> {
const id = await openOne();
try { return await fn(id); } finally { await closeOne(id, 'read-only'); }
},
/**
* 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.
*/
async edit<T>(fn: (id: string, ctl: EditCtl) => Promise<T>, o: { dry?: boolean; commitQuery?: string; retries?: number } = {}): Promise<T> {
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)`);
}
}
},
};
}