Python library

goopdl is also a Python package you can import from a script, a web service, or a worker. This guide covers the public API: the Client facade (goopdl.client, also importable from goopdl), the functions in goopdl.api, goopdl.auth, and goopdl.download, and goopdl.errors. Everything else (goopdl.cli, underscore names) is internal and can change without notice.

Requirements

Importing goopdl, goopdl.client, goopdl.api, goopdl.auth, goopdl.download, or goopdl.errors does not import rich, typer, websocket-client, or keyring, prints nothing, and touches no files. Only these load them:

Module or call Loads
goopdl.cli typer, rich
download.download_batch() and api.download_app() (progress bars) rich
goopdl.browser_oauth websocket-client
CLI commands that read or save a Google account in the keyring keyring

Client

Client is the entry point for backends. It takes its settings from a Config object instead of environment variables, so one process can serve several accounts and cache directories at once, from threads or asyncio. It wraps the same functions the CLI uses, which stay available for step-by-step control (see quick start onwards).

Quick start

python
from pathlib import Path

from goopdl import Client, Config

client = Client(Config(config_dir=Path("goopdl-cache").resolve(), country="US"))

details = client.details("com.whatsapp")
print(details.title, details.version_string, details.version_code)

result = client.download("com.whatsapp", Path("apks"), splits=True)
print(result.path, result.version_string, len(result.files))

Client(config=None, **overrides)

Client(config, **overrides) is short for Client(config.replace(**overrides)), and Client(country="US") builds the Config for you. download returns a DownloadResult: path is the base APK, files every file written (base APK first), version_string the dotted version. Unlike api.download_app, it draws no progress bars.

Config

Config(config_dir=None, credentials=None, arch="arm64", country=None, proxy=None, profile=None, dispenser_url=None)

Config.replace(**changes) -> Config

A frozen dataclass. replace returns a validated copy.

Field Meaning
config_dir Token cache directory. Must be absolute after ~ expansion (ValueError otherwise). None resolves it on each call like the CLI, see token pool and cache
credentials A Credentials for account mode, or None for the anonymous dispenser
arch One of profiles.VALID_ARCHS, checked at once (InvalidArchError)
country, proxy, profile, dispenser_url As in concepts

repr(config) hides the proxy (it may hold a password) and masks the credentials.

Credentials

Credentials(email, aas_token)

A Google account for account mode. It is checked like the environment variables: both values non-empty (they are stripped), the token starting with aas_et/. Otherwise it raises DirectAuthConfigurationError, whose message names the environment variable it would be. repr and str show neither the token nor the full email.

python
from goopdl import Credentials

account = Client(
    config_dir=Path("goopdl-cache/me").resolve(),
    credentials=Credentials("me@example.com", "aas_et/AKppINa..."),  # load it from your secret store
)
print(account.config.credentials)  # Credentials(email=m***@example.com, aas_token=<hidden>)
print(account.details("com.whatsapp").version_code)

A Client never reads GOOPDL_ACCOUNT_EMAIL or GOOPDL_AAS_TOKEN. credentials=None means anonymous mode even when those variables are set, so a server can't switch accounts because of its environment. For the CLI's behaviour, ask for it:

Credentials.from_env() -> Credentials | None

python
cli_like = Client(credentials=Credentials.from_env())  # None (anonymous) when neither is set

It raises DirectAuthConfigurationError if only one of the two is set. GOOPDL_CONFIG_DIR and XDG_CONFIG_HOME still apply when config_dir is None.

The library never reads the account saved by goopdl auth login (account.json and the system keyring), neither in a Client nor in the module functions. Only the CLI does.

Methods

Method Returns Does
details AppDetails Pool token + get_details
search list[dict] Pool token + search_apps
list_splits list[str] Pool token + list_splits
item dict fetch_app_item
delivery DeliveryResult Pool token, then purchase and get_delivery with that same token
download DownloadResult Like download_app, without progress bars
adownload DownloadResult download for asyncio
clear_cache None Delete what this client cached, see clearing the cache

Details:

Clearing the cache

clear_cache() deletes what the client cached in its config_dir: the anonymous token files and, in account mode, this account's device record and its signed-in token in memory. Other accounts sharing the directory keep theirs. Without credentials it is auth.clear_auth() for that directory, which also deletes every account's device record, like goopdl auth clear-cache. Lock files stay, and so does the account saved by goopdl auth login, file and keyring entry: only goopdl auth logout deletes it.

python
account.clear_cache()  # the next call registers a new device and signs in again

Async

adownload runs the blocking part (token, details, purchase, delivery) in a worker thread with asyncio.to_thread, then downloads natively with download_files. http_client is used for the CDN downloads and never closed. The other methods are blocking: offload them yourself.

python
import asyncio


async def latest(package):
    client = Client(config_dir=Path("goopdl-cache").resolve())
    details = await asyncio.to_thread(client.details, package)
    result = await client.adownload(
        package, Path("apks"),
        on_progress=lambda label, done, total: None,  # runs on the event loop
    )
    return details.version_code, result.path


print(asyncio.run(latest("com.whatsapp")))

Cancelling adownload during the download removes its .part files. During the worker-thread part, the thread finishes its current step first, as with any asyncio.to_thread call.

Several tenants

One Client per tenant, each with its own cache directory and, if needed, its own account. They can run at the same time:

python
tenants = {
    "acme": Client(config_dir=Path("cache/acme").resolve(), country="US"),
    "globex": Client(config_dir=Path("cache/globex").resolve(), country="DE"),
}


async def download_for_all(package):
    return await asyncio.gather(
        *(client.adownload(package, Path("apks") / name) for name, client in tenants.items())
    )


for result in asyncio.run(download_for_all("com.whatsapp")):
    print(result.path)

Threads and context

A Client holds no mutable state: share one between threads and tasks. Each call applies its Config through a contextvars variable that it sets inside the thread doing the work, so concurrent calls never see each other's cache directory or account. The file locks of the token pool still serialize callers that share a cache directory.

The module functions in goopdl.api and goopdl.auth don't change: called directly, they read the cache directory and the account from the environment on each call, as before.

Quick start

One call

download_app gets a token, finds the latest version, and downloads the base APK into a directory:

python
from pathlib import Path

from goopdl import api

apk, version = api.download_app("com.whatsapp", Path("apks"))
print(apk, version)  # apks/com.whatsapp-<version code>.apk and the version string

It is synchronous and draws Rich progress bars on stdout. For a service, use the Client, or the step-by-step pipeline below, and see async backends.

Step by step

The same pipeline the CLI runs, one call per step (see how it works):

python
from pathlib import Path

from goopdl import api, auth
from goopdl.download import DownloadSpec, download_batch

package = "com.whatsapp"
token = auth.pick_pool_token(arch="arm64", raise_on_error=True)
if token is None:
    raise SystemExit("no token: unknown profile or the cache directory is unusable")

details = api.get_details(package, token)
delivery_token = api.purchase(package, details.version_code, token)
delivery = api.get_delivery(
    package, details.version_code, token, delivery_token=delivery_token
)


def base_apk_spec(package, version_code, delivery, output):
    """Turn a DeliveryResult into a verified download of the base APK."""
    return DownloadSpec(
        url=delivery.gzipped_url or delivery.download_url,
        dest=output / f"{package}-{version_code}.apk",
        cookies=delivery.cookies,
        gzipped=bool(delivery.gzipped_url),
        integrity_required=True,
        expected_size=delivery.download_size,
        sha1=delivery.sha1,
        sha256=delivery.sha256,
    )


output = Path("apks")
output.mkdir(exist_ok=True)
spec = base_apk_spec(package, details.version_code, delivery, output)
download_batch([spec], require_integrity=True)
print(spec.dest)

The rest of this guide reuses package, token, details, delivery, output, and base_apk_spec from this snippet.

Concepts

Auth dicts

Every Google Play call takes an auth dict: a bearer token (authToken) plus the device identity it was issued for (gsfId, device tokens, user agent). Treat it as an opaque secret. Pass it back unchanged, don't log it, and don't edit it. goopdl adds _device_profile and _cached_at keys.

Tokens come from one of two places, picked automatically on every call:

Mode Source Cached on disk
Anonymous (default) Aurora's token dispenser, or dispenser_url Yes, see token pool
Google account GOOPDL_ACCOUNT_EMAIL and GOOPDL_AAS_TOKEN in the environment, or a Client's Credentials Only the device registration, no token. The token stays in memory

Account mode is described in authentication. The module functions read the variables from os.environ on each call, so for them they apply to the whole process; a Client uses only its own credentials. If only one is set, or the token doesn't start with aas_et/, every token function raises DirectAuthConfigurationError. In account mode pick_pool_token, replace_pool_token, and ensure_auth share one sign-in per cache directory, account, architecture, country, and profile, held in process memory until it expires or Google rejects it with HTTP 401. Concurrent callers wait for a single sign-in. fetch_token and ensure_pool still sign in on a new device on every call.

To get an AAS token from code there are two helpers. Both talk to Google and need a person in the loop, so they belong in a setup script, not a request handler:

python
# Not run by the docs check: needs a real Google account and a browser.
from goopdl.aastoken import fetch_aas_token
from goopdl.browser_oauth import capture_oauth_credentials

email, oauth_token = capture_oauth_credentials(timeout=300)  # opens Chrome/Edge/Chromium/Brave
aas_token = fetch_aas_token(email, oauth_token)              # "aas_et/..."

fetch_aas_token takes only the one-time oauth2_4/ token: anything else, a password included, raises ValueError before any request. It raises AASTokenError when Google rejects the token, and sanitized httpx errors on HTTP or transport failures. capture_oauth_credentials raises BrowserOAuthError.

Token pool and cache

pick_pool_token keeps a pool of anonymous tokens per architecture, country, and pinned profile, and hands them out round-robin. It requests only the missing tokens, and a full pool makes no network calls. If the dispenser can't top it up (HTTP 429 or an outage), the valid tokens left still serve the call. Tokens are dropped after 50 minutes. The pool is explained in how it works.

After a dispenser HTTP 429, the process stops asking that dispenser (the same dispenser_url and proxy) until the server's Retry-After has passed, capped at 15 minutes, or for 60 seconds without a usable header. The 60 seconds are a heuristic: the dispenser doesn't say how long its limit lasts. Inside that window the token functions send nothing and act as if the dispenser had answered 429 again: valid pool tokens keep serving calls without a refill, and with none left you get DispenserRateLimitedError (RateLimitedError from Client, fetch_app_item, and download_app) with retry_after set to the seconds left, rounded up. The window lives in this process's memory only: it isn't saved, and other processes don't see it. auth.clear_auth() and clear_cache() on a Client without credentials forget it. Account mode doesn't use the dispenser, so it has no window.

The cache directory is resolved on every call by auth.get_config_dir():

  1. Inside a Client call, its config_dir if set
  2. $GOOPDL_CONFIG_DIR (~ is expanded)
  3. $XDG_CONFIG_HOME/goopdl
  4. ~/.config/goopdl

A relative value in either variable is ignored (GOOPDL_CONFIG_DIR logs a warning), so the cache never lands in the working directory.

python
from goopdl import auth

print(auth.get_config_dir())
File Written by
token-pool-{arch}[-{country}][-profile-{key}].json pick_pool_token, ensure_pool, replace_pool_token
pool-index-{arch}[-{country}][-profile-{key}].json pick_pool_token (round-robin position)
token-pool-…lock Lock sidecars, never deleted
auth-{arch}[-{country}][-profile-{key}].json ensure_auth (single cached token)
direct-device-{account}-{arch}[-{country}]-profile-{key}.json Account mode: the registered device, keyed by a hash of the email

On POSIX the directory is created 0700 (an existing one you own is tightened to 0700) and files are written atomically as 0600. auth.clear_auth() deletes the JSON files and keeps the lock sidecars. In account mode the only file is the device registration (GSF ID, check-in consistency token, device config token). The email, the AAS token, and the bearer token are never written; if the directory isn't writable, the registration stays in memory. See what account mode saves.

Device profiles and architectures

The device profile decides which build Google serves. profile takes a profile key ("Pv") or a case-insensitive substring of its name ("Pixel 9a"). Without one, goopdl rotates through profiles in priority order.

python
from goopdl import profiles

print(profiles.VALID_ARCHS)  # ('arm64', 'armv7', 'x86', 'x86_64', 'tv')
for key, props in profiles.get_priority_profiles("arm64")[:3]:
    print(key, props["UserReadableName"])

Country and proxy

country is a 2-letter code such as "US" or "IN". It sets Google's gl parameter and the language headers, patches the device's MCC/MNC for known countries, and keys the cache files. On its own it only changes headers. For a region's real variant, also send traffic from that region with proxy.

proxy is a URL string (http://user:pass@host:8080). It is used for the dispenser and the Google Play API calls. For the dispenser it is rotated across regions: put {region} in the URL, or goopdl swaps the country code it finds in the proxy password. SOCKS proxies need pip install "httpx[socks]".

download_files does not take a proxy. To route CDN downloads through one, pass your own client: client=httpx.AsyncClient(proxy=..., timeout=...).

API reference

All functions in goopdl.api and goopdl.auth are synchronous and blocking, and so are the Client methods except adownload. They use synchronous httpx with a 30-second timeout, reuse keep-alive connections, and may time.sleep (rate-limit backoff, dispenser cooldowns, lock waits). goopdl.download.download_files and Client.adownload are the only coroutines.

goopdl.api

Function Returns
get_details AppDetails
get_details_raw Raw details response as a nested dict
fetch_app_item Flat app metadata dict, token handled for you
search_apps list[dict] of search hits
list_splits list[str] of split names
purchase Delivery token (str, may be empty)
get_delivery DeliveryResult with download URLs
download_app (apk_path, version_string)
validate_package The package name, or raises

get_details

get_details(package, auth, country=None, proxy=None) -> AppDetails

python
details = api.get_details("com.whatsapp", token, country="US")
print(details.title, details.developer, details.version_string, details.version_code)

AppDetails fields: package, title, developer, version_string, version_code, rating, downloads, play_url. Raises AppNotAvailableError on HTTP 404 or when Play returns no document for this device profile.

get_details_raw

get_details_raw(package, auth, country=None, proxy=None) -> dict

The whole response, decoded generically. Keys are protobuf field numbers as strings:

python
raw = api.get_details_raw("com.whatsapp", token)
doc = raw["1"]["2"]["4"]  # DocV2; missing if Play sent no document
print(doc["5"])           # title

fetch_app_item

fetch_app_item(package, arch="arm64", country=None, proxy=None, profile=None, dispenser_url=None) -> dict

Picks a pool token, fetches raw details, replaces the token once on HTTP 401, and maps the result to a flat dict (title, version, rating, screenshots, permissions...). Returns {} when Play sends no document.

python
item = api.fetch_app_item("com.whatsapp", country="US")
print(item.get("title"), item.get("version"))

It doesn't let dispenser errors through. If the dispenser answers HTTP 429 while replacing an expired token and no other valid pool token is left, you get RateLimitedError with the same retry_after, and its __cause__ is the DispenserRateLimitedError. Any other failure to get a token, a busy pool included, is a plain PlayAPIError.

python
from goopdl.errors import RateLimitedError

try:
    item = api.fetch_app_item("com.whatsapp")
except RateLimitedError as exc:
    # Play's 429, or the dispenser's with no valid pool token left.
    print("rate limited, retry after", exc.retry_after)

search_apps

search_apps(query, auth, limit=10, country=None, proxy=None) -> list[dict]

python
for hit in api.search_apps("messenger", token, limit=5):
    print(hit["package"], hit["title"], hit["creator"])

list_splits

list_splits(package, auth, country=None, proxy=None) -> list[str]

Split names from the details metadata, sorted. HTTP 404 raises a plain PlayAPIError with status_code=404.

python
print(api.list_splits("com.whatsapp", token))  # e.g. ['config.arm64_v8a', ...]

purchase

purchase(package, version_code, auth, country=None, proxy=None) -> str

"Buys" a free app and returns Play's delivery token, or "". Other non-200 answers are not errors: the app may already be owned, and get_delivery reports the real problem. HTTP 401 and 429 raise.

python
delivery_token = api.purchase("com.whatsapp", details.version_code, token)

get_delivery

get_delivery(package, version_code, auth, country=None, proxy=None, delivery_token="", locales=None) -> DeliveryResult

python
delivery = api.get_delivery(
    "com.whatsapp", details.version_code, token,
    delivery_token=delivery_token, locales=["es", "fr"],
)
print(delivery.download_size, [s.name for s in delivery.splits])

To download the splits too, build one spec per SplitInfo. Split names come from the server, so keep them to safe file-name characters:

python
import re


def split_specs(package, version_code, delivery, output):
    specs = []
    for split in delivery.splits:
        name = re.sub(r"[^A-Za-z0-9._-]+", "_", f"{package}-{version_code}-{split.name}.apk")
        specs.append(DownloadSpec(
            url=split.gzipped_url or split.url,
            dest=output / name,
            gzipped=bool(split.gzipped_url),
            integrity_required=True,
            expected_size=split.size,
            sha1=split.sha1,
            sha256=split.sha256,
        ))
    return specs


specs = [base_apk_spec(package, details.version_code, delivery, output)]
specs += split_specs(package, details.version_code, delivery, output)

OBB files and asset packs (delivery.additional_files) come without digests: download them with integrity_required=False.

download_app

download_app(package, output, arch="arm64", country=None, proxy=None, dispenser_url=None, profile=None, no_splits=True, no_extras=True) -> tuple[Path, str]

python
apk, version = api.download_app("com.whatsapp", Path("apks"), no_splits=False)

validate_package

validate_package(package) -> str

Every api function that takes a package name calls it first, before any network request.

python
from goopdl.errors import InvalidPackageError

api.validate_package("com.whatsapp")  # returns "com.whatsapp"
try:
    api.validate_package("whatsapp")
except InvalidPackageError as exc:
    print(exc)  # Invalid package name: 'whatsapp'

goopdl.auth

All token functions accept arch, country, proxy, dispenser_url, and profile with the meanings from concepts. They return None (or []) on failure unless you pass raise_on_error=True, which raises the typed errors from errors instead. Use raise_on_error=True in services: None doesn't tell a rate limit from an outage.

fetch_token

fetch_token(dispenser_url=None, arch="arm64", proxy=None, country=None, profile=None, raise_on_error=False, *, cooldown_retry=True) -> dict | None

One fresh token, not cached. Each call is a dispenser request (Aurora allows about 20 per hour), so prefer pick_pool_token. Inside a 429 window it sends nothing: it returns None, or raises DispenserRateLimitedError with raise_on_error=True.

python
fresh = auth.fetch_token(arch="arm64", country="US", raise_on_error=True)

Without profile it rotates profiles until one works, and stops on HTTP 429 or after two transport/5xx failures in a row. With profile it retries once after 5 seconds on transport/5xx failures, unless cooldown_retry=False.

pick_pool_token

pick_pool_token(arch="arm64", country=None, proxy=None, dispenser_url=None, profile=None, size=5, raise_on_error=False) -> dict | None

Fills the pool up to size valid tokens, then returns the next one round-robin. If the dispenser stops issuing tokens (HTTP 429 included), it uses the valid ones it has; raise_on_error=True raises only when none is left. Used in the quick start. It can return None even with raise_on_error=True (unknown profile, unusable cache directory), so check the result.

ensure_pool

ensure_pool(arch="arm64", country=None, proxy=None, dispenser_url=None, profile=None, size=5, raise_on_error=False) -> list[dict]

Warms the pool without picking a token, for example at worker start-up. It may return fewer than size tokens if the dispenser stops issuing them (HTTP 429 included). With raise_on_error=True it raises only when no valid token is left.

python
tokens = auth.ensure_pool(arch="arm64", size=5, raise_on_error=True)
print(len(tokens))

replace_pool_token

replace_pool_token(failed_token, arch="arm64", country=None, proxy=None, dispenser_url=None, profile=None, raise_on_error=False) -> dict | None

Call it after AuthExpiredError, with the same arch, country, and profile you picked the token with. It removes the dead token and returns a fresh one. If the replacement fails it returns a surviving pool member (or None). On a dispenser HTTP 429 it returns a surviving member too, even with raise_on_error=True. Only when no valid token is left does it raise DispenserRateLimitedError, even without raise_on_error.

python
from goopdl.errors import AuthExpiredError


def details_with_refresh(package, token, arch="arm64"):
    try:
        return api.get_details(package, token), token
    except AuthExpiredError:
        token = auth.replace_pool_token(token, arch=arch, raise_on_error=True)
        if token is None:
            raise
        return api.get_details(package, token), token


details, token = details_with_refresh(package, token)

Other helpers

Function What it does
get_config_dir() -> Path The cache directory in effect now
ensure_auth(arch="arm64", dispenser_url=None, force_refresh=False, proxy=None, country=None, profile=None) -> dict | None One cached token in auth-*.json, refreshed after 50 minutes (account mode: the in-memory sign-in). Never raises dispenser errors
clear_auth() -> None Delete every cached token and account device record, and forget this directory's in-memory sign-ins
direct_auth_enabled() -> bool Whether account mode is configured. Raises DirectAuthConfigurationError if half-configured

goopdl.download

A DownloadSpec describes one file: url, dest, cookies, label, gzipped, integrity_required, expected_size, sha1, sha256. goopdl streams it to a unique .part file next to dest, checks it, then renames it into place. A file already at dest with the right size and digest is not downloaded again.

download_files

download_files(specs, *, require_integrity=False, on_progress=None, max_concurrent=4, client=None) -> None

The async downloader. It writes nothing to stdout.

python
import asyncio

import httpx

from goopdl.download import download_files


def on_progress(label, done, total):
    print(f"{label}: {done}/{total or '?'} bytes")


async def main():
    timeout = httpx.Timeout(connect=15.0, read=300.0, write=30.0, pool=30.0)
    async with httpx.AsyncClient(timeout=timeout) as client:
        await download_files(
            specs, require_integrity=True, on_progress=on_progress,
            max_concurrent=2, client=client,
        )


asyncio.run(main())

download_batch

download_batch(specs, require_integrity=False) -> None

A synchronous wrapper around download_files with Rich progress bars on stdout. It raises RuntimeError if called while an event loop is running; from async code, await download_files.

Integrity

write_manifest

write_manifest(path, specs) -> None

Writes the integrity manifest JSON atomically. Every spec needs digest metadata, so pass only verified files:

python
from goopdl.download import write_manifest

write_manifest(output / "manifest.json", [s for s in specs if s.integrity_required])

Async backends

Offload the blocking calls

Client.adownload does this for you. To control each step, note that no api or auth function is async, and they can block for seconds or minutes (a cold pool mints up to five tokens, each a dispenser request with a 30-second timeout). Run them in a worker thread with asyncio.to_thread, and await download_files directly:

python
import asyncio
from pathlib import Path

from goopdl import api, auth
from goopdl.download import download_files


def resolve_latest(package, arch="arm64"):
    """Blocking: token, details, purchase, and delivery."""
    token = auth.pick_pool_token(arch=arch, raise_on_error=True)
    if token is None:
        raise RuntimeError("no token available")
    details = api.get_details(package, token)
    delivery_token = api.purchase(package, details.version_code, token)
    delivery = api.get_delivery(
        package, details.version_code, token, delivery_token=delivery_token
    )
    return details, delivery


async def download_latest(package, output):
    api.validate_package(package)  # fail fast, before using a thread
    details, delivery = await asyncio.to_thread(resolve_latest, package)
    output.mkdir(parents=True, exist_ok=True)
    spec = base_apk_spec(package, details.version_code, delivery, output)
    await download_files([spec], require_integrity=True)
    return spec.dest


print(asyncio.run(download_latest("com.whatsapp", Path("apks"))))

Don't call download_app or download_batch from a coroutine: they call asyncio.run and raise RuntimeError. In a worker thread they run, but print progress bars to your server's stdout.

FastAPI example

python
# Not run by the docs check: needs FastAPI. download_latest is defined above.
from pathlib import Path

from fastapi import FastAPI, HTTPException

from goopdl.errors import (
    AppNotAvailableError,
    DispenserRateLimitedError,
    GoopdlError,
    InvalidPackageError,
    RateLimitedError,
)

app = FastAPI()


@app.post("/apps/{package}/download")
async def download(package: str):
    try:
        path = await download_latest(package, Path("/srv/apks"))
    except InvalidPackageError:
        raise HTTPException(422, "invalid package name")
    except AppNotAvailableError:
        raise HTTPException(404, "app not available")
    except (RateLimitedError, DispenserRateLimitedError) as exc:
        headers = {"Retry-After": str(int(exc.retry_after))} if exc.retry_after else None
        raise HTTPException(503, "rate limited", headers=headers)
    except GoopdlError as exc:
        raise HTTPException(502, type(exc).__name__)  # messages carry no secrets, but stay terse
    return {"file": path.name}

Concurrency

Tenants

Use one Client per tenant, each with its own config_dir and credentials: they share the process safely.

The module functions take the cache directory and account credentials from environment variables that apply to the whole process. Don't change them per request: other threads would read the new values mid-call. With the functions alone, isolate tenants with one worker process each:

bash
GOOPDL_CONFIG_DIR=/var/lib/goopdl/tenant-a python worker.py
GOOPDL_CONFIG_DIR=/var/lib/goopdl/tenant-b python worker.py

Errors

Hierarchy

Every goopdl exception derives from GoopdlError. Import them from goopdl.errors (or goopdl). Importing that module doesn't load httpx; each class loads its own module on first access. dir(goopdl) and tab completion list them too.

text
GoopdlError
├── PlayAPIError                       .status_code
│   ├── NetworkError                   also httpx.TransportError
│   ├── AuthExpiredError
│   ├── RateLimitedError               .retry_after, status_code=429
│   ├── VersionUnavailableError
│   │   └── AppNotSupportedError
│   ├── AppNotAvailableError
│   ├── AppNotPurchasedError
│   └── MalformedResponseError
├── InvalidPackageError                also ValueError
├── InvalidArchError                   also ValueError
├── DirectAuthConfigurationError       also ValueError
├── DirectAuthError                    also RuntimeError; .stage, .status, .retry_after
│   ├── DispenserRateLimitedError      .retry_after, status=429
│   └── DispenserUnavailableError      .status, .reason
├── PoolBusyError                      also TimeoutError
├── AASTokenError                      also ValueError
├── BrowserOAuthError                  also RuntimeError
└── IntegrityError                     also RuntimeError
    └── DownloadError                  .status_code, .retryable, .retry_after

NetworkError is still an httpx.TransportError (and so an httpx.HTTPError), so old except httpx.HTTPError handlers keep working.

The dispenser errors are not PlayAPIErrors: catch DirectAuthError for any token failure. download_app and fetch_app_item translate some of them, see below.

What raises what

Error Raised by When Retry?
InvalidPackageError api functions that take a package Malformed application ID, before any request No
InvalidArchError Pool and cache functions, download_app, fetch_app_item, Config arch not in VALID_ARCHS No
DirectAuthConfigurationError Every token function, Credentials, Credentials.from_env Account mode half-configured No, fix the environment or the values
NetworkError Google Play calls in api Timeout, DNS, refused or dropped connection Yes, with backoff
AuthExpiredError get_details*, search_apps, list_splits, purchase, get_delivery HTTP 401 Once, after replace_pool_token
RateLimitedError Same, plus fetch_app_item and download_app HTTP 429 from Play (get_delivery already retried twice). In download_app, also a dispenser or account-mode 429; in fetch_app_item, a dispenser 429 while replacing an expired token with no valid pool token left. For the dispenser's, __cause__ is the DispenserRateLimitedError Only after retry_after; stop if it is None
AppNotAvailableError get_details, get_details_raw, fetch_app_item HTTP 404, or no document for this device Not with the same profile and country
VersionUnavailableError get_delivery HTTP 404, or no download URL No
AppNotSupportedError get_delivery The profile can't receive this version With another profile or arch
AppNotPurchasedError get_delivery Paid app, or not offered in the region No
MalformedResponseError api parsers Undecodable protobuf Not with another token
PlayAPIError All of api Any other HTTP status (.status_code); no token in fetch_app_item / download_app 5xx later, others no
DispenserRateLimitedError Token functions with raise_on_error=True; replace_pool_token even without it. Pool functions raise it only when no valid pool token is left. fetch_app_item and download_app wrap it in RateLimitedError Dispenser HTTP 429, or a call inside the window a 429 opened, with no request; then retry_after is the time left Only after retry_after
DispenserUnavailableError Token functions with raise_on_error=True .reason: transport, server (5xx), or profile_rejected transport/server later; profile_rejected with another profile
DirectAuthError Token functions in account mode with raise_on_error=True Sign-in failed at .stage (checkin, device config upload, token exchange) with .status. A 429 on the optional toc call is not an error: the token comes without dfeCookie and a warning is logged 429 after .retry_after; transport/5xx later; not 400/401/403
PoolBusyError Pool functions with raise_on_error=True; download_app always Pool lock not acquired in 180 s Later
IntegrityError download_files, download_batch, write_manifest Missing metadata, size or digest mismatch No
DownloadError download_files, download_batch CDN HTTP or transport failure, or a malformed redirect Location (never retryable) If .retryable; on 403 request a new delivery

download_app and fetch_app_item don't let dispenser errors through. A 429 becomes RateLimitedError with the same retry_after and the original as __cause__. In download_app a dispenser outage becomes PlayAPIError with status_code; in fetch_app_item any other failure to get a token is a PlayAPIError. Not everything they raise is a PlayAPIError, though: download_app can also raise PoolBusyError, DownloadError, IntegrityError, InvalidPackageError, InvalidArchError, and DirectAuthConfigurationError, and fetch_app_item the last three. End with except GoopdlError.

Also expect ValueError from download_files (bad max_concurrent), RuntimeError from download_batch in an event loop, OSError from your output directory, and from fetch_aas_token, sanitized httpx errors.

Retrying

Retry only what the error says is retryable, and wait as long as the server asks. Don't retry a 429 without a Retry-After: back off at a higher level, or switch network or account. get_delivery has already retried twice when it raises.

python
import time

from goopdl.errors import DispenserRateLimitedError, NetworkError, RateLimitedError


def call_with_backoff(fn, *args, attempts=3, max_wait=60.0, **kwargs):
    for attempt in range(attempts):
        try:
            return fn(*args, **kwargs)
        except (RateLimitedError, DispenserRateLimitedError) as exc:
            wait = exc.retry_after
            if wait is None or wait > max_wait or attempt == attempts - 1:
                raise  # no hint from the server, or too long: let the caller decide
        except NetworkError:
            if attempt == attempts - 1:
                raise
            wait = 2 ** attempt
        time.sleep(wait)


details = call_with_backoff(api.get_details, package, token)

It works for fetch_app_item and download_app too: they report every 429, the dispenser's included, as RateLimitedError. Inside the dispenser's 429 window, retry_after is the time left, so sleeping that long is enough. DispenserRateLimitedError only reaches you from the token functions in goopdl.auth.

For downloads, DownloadError tells you what to do. A 403 means the signed URLs expired, so a new delivery is needed. Other retryable errors can reuse the same URLs:

python
from goopdl.errors import DownloadError


async def download_with_retries(package, output, attempts=3):
    details, delivery = await asyncio.to_thread(resolve_latest, package)
    for attempt in range(attempts):
        spec = base_apk_spec(package, details.version_code, delivery, output)
        try:
            await download_files([spec], require_integrity=True)
            return spec.dest
        except DownloadError as exc:
            if attempt == attempts - 1:
                raise
            if exc.status_code == 403:
                details, delivery = await asyncio.to_thread(resolve_latest, package)
            elif exc.retryable:
                await asyncio.sleep(exc.retry_after or 2 ** attempt)
            else:
                raise


print(asyncio.run(download_with_retries("com.whatsapp", Path("apks"))))

Catch DownloadError before IntegrityError, since it is a subclass. An IntegrityError that isn't a DownloadError means the bytes or metadata are wrong: don't retry it blindly.

Security

Logging

goopdl logs to the goopdl logger tree, with a NullHandler on goopdl, so it is silent until your application configures logging. It never logs tokens. Messages use INFO (pool refills, chosen profile), WARNING (dispenser failures, naming the device profile), and ERROR. To see everything:

python
import logging

logging.basicConfig(level=logging.WARNING)
logging.getLogger("goopdl").setLevel(logging.DEBUG)
logging.getLogger("httpx").setLevel(logging.WARNING)  # its INFO lines include full request URLs

Keep httpx at WARNING or above: at INFO it logs every request URL, including delivery tokens and signed CDN URLs.

Migrating from 1.4.x

Existing calls keep their signatures (new parameters are optional). These behaviours changed:

Change 1.4.x Now Migration
Corrupt protobuf Raw ValueError / EOFError MalformedResponseError (a PlayAPIError) except PlayAPIError covers it
Package names Sent to Google as given InvalidPackageError (a ValueError) if there is no dot or a bad character Call api.validate_package() at your input boundary
Unknown arch Used in cache file names InvalidArchError (a ValueError) Check against profiles.VALID_ARCHS
Cache directory Always ~/.config/goopdl auth.get_config_dir(): $GOOPDL_CONFIG_DIR, $XDG_CONFIG_HOME/goopdl, ~/.config/goopdl export GOOPDL_CONFIG_DIR=~/.config/goopdl keeps old tokens
download_app with a busy token pool PlayAPIError PoolBusyError (a TimeoutError, not a PlayAPIError) except PoolBusyError, or end with except GoopdlError
typer dependency typer>=0.9 typer>=0.16 Raise your pin if it is lower
CDN failures IntegrityError("download failed …") DownloadError, a subclass of IntegrityError except DownloadError before except IntegrityError
Transport failures in api Raw httpx exceptions (httpx.ConnectError, …) NetworkError, still an httpx.TransportError except NetworkError; handlers for specific httpx subclasses no longer match
purchase on HTTP 429 Returned "" Raises RateLimitedError (401 already raised AuthExpiredError) Handle it like any other 429
CDN redirects Followed anywhere At most five, Google hosts only None, unless you rely on other hosts
Console output goopdl.auth printed to stderr with Rich Token functions log instead of printing Configure logging
goopdl.auth.console / Console Rich objects Deprecated, DeprecationWarning rich.console.Console(stderr=True)
goopdl.download.Progress, BarColumn, … Re-exported from Rich Deprecated, DeprecationWarning from rich.progress import Progress

New and backwards compatible: the GoopdlError base, raise_on_error on the token functions, download_files, and RateLimitedError from download_app and fetch_app_item where 1.4.x raised a plain PlayAPIError.

Troubleshooting

Symptom Cause and fix
RuntimeError: download_batch() cannot run inside a running event loop Await download_files instead, see async backends
Progress bars in your server logs download_app / download_batch draw them. Use Client.download, Client.adownload, or download_files
pick_pool_token returns None Pass raise_on_error=True to get the typed reason. Still None: unknown profile or unusable cache directory (see the logs)
PoolBusyError Another thread or process is filling the same pool. Retry later
Tokens are minted again after upgrading The cache moved, see migration
DirectAuthConfigurationError Set both GOOPDL_ACCOUNT_EMAIL and GOOPDL_AAS_TOKEN, or neither. From Credentials: pass both values, the token starting with aas_et/
A Client ignores GOOPDL_ACCOUNT_EMAIL By design, see credentials. Use Credentials.from_env()
DownloadError with status_code == 403 The delivery URLs expired. Call get_delivery again
AppNotAvailableError for an app that exists It is hidden from this device profile or country. Try another profile, arch, or country with a matching proxy

Known limits

Edit this page on GitHub ↗