Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 40 additions & 42 deletions app_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,17 +579,10 @@ def _enrich_app_store_versions(rows: list[dict[str, Any]]) -> list[dict[str, Any
and _should_lookup_app_store_version(row)
)
)
lookup_bundle_ids = bundle_ids[:APP_STORE_LOOKUP_LIMIT]
if len(bundle_ids) > APP_STORE_LOOKUP_LIMIT:
logging.warning(
"Skipping App Store lookup for %s iOS bundle IDs over the %s-bundle limit.",
len(bundle_ids) - APP_STORE_LOOKUP_LIMIT,
APP_STORE_LOOKUP_LIMIT,
)
app_store_versions: dict[str, dict[str, Any]] = {}
if lookup_bundle_ids:
if bundle_ids:
try:
app_store_versions = _fetch_app_store_versions(lookup_bundle_ids)
app_store_versions = _fetch_app_store_versions(bundle_ids)
except requests.RequestException as exc:
logging.warning("App Store version lookup failed: %s", exc)
except ValueError as exc:
Expand Down Expand Up @@ -617,46 +610,51 @@ def _enrich_app_store_versions(rows: list[dict[str, Any]]) -> list[dict[str, Any


def _fetch_app_store_versions(bundle_ids: list[str]) -> dict[str, dict[str, Any]]:
def lookup(ids: list[str]) -> list[dict[str, Any]]:
response = requests.get(
APP_STORE_LOOKUP_URL,
params={"bundleId": ",".join(ids), "country": "us"},
timeout=APP_STORE_LOOKUP_TIMEOUT_SECONDS,
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
raise ValueError("Invalid App Store lookup response")
return payload["results"]

batches = [
bundle_ids[i : i + APP_STORE_LOOKUP_LIMIT]
for i in range(0, len(bundle_ids), APP_STORE_LOOKUP_LIMIT)
]
app_store_versions: dict[str, dict[str, Any]] = {}
max_workers = min(APP_STORE_LOOKUP_WORKERS, len(bundle_ids))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_by_bundle_id = {
executor.submit(_fetch_app_store_version, bundle_id): bundle_id
for bundle_id in bundle_ids
}
for future in as_completed(future_by_bundle_id):
bundle_id = future_by_bundle_id[future]
with ThreadPoolExecutor(max_workers=min(APP_STORE_LOOKUP_WORKERS, len(batches))) as executor:
futures = {executor.submit(lookup, batch): batch for batch in batches}
while futures:
future = next(as_completed(futures))
batch = futures.pop(future)
try:
result = future.result()
results = future.result()
except (requests.RequestException, ValueError) as exc:
logging.warning("App Store version lookup failed for %s: %s", bundle_id, exc)
response = (
getattr(exc, "response", None) if isinstance(exc, requests.HTTPError) else None
)
if response is not None and response.status_code == 400 and len(batch) > 1:
mid = len(batch) // 2
for smaller_batch in (batch[:mid], batch[mid:]):
futures[executor.submit(lookup, smaller_batch)] = smaller_batch
else:
logging.warning("App Store version lookup failed for %s: %s", batch, exc)
continue
if result:
app_store_versions[result["bundleId"]] = result
for result in results:
if (
isinstance(result, dict)
and result.get("bundleId") in batch
and _string_value(result.get("version"))
):
app_store_versions[result["bundleId"]] = result
return app_store_versions


def _fetch_app_store_version(bundle_id: str) -> dict[str, Any] | None:
response = requests.get(
APP_STORE_LOOKUP_URL,
params={
"bundleId": bundle_id,
"country": "us",
},
timeout=APP_STORE_LOOKUP_TIMEOUT_SECONDS,
)
response.raise_for_status()
payload = response.json()
for result in payload.get("results", []):
if not isinstance(result, dict):
continue
fetched_bundle_id = _string_value(result.get("bundleId"))
version = _string_value(result.get("version"))
if fetched_bundle_id and version:
return {**result, "bundleId": fetched_bundle_id}
return None


def _should_lookup_app_store_version(row: dict[str, Any]) -> bool:
platform = (_string_value(row.get("apollos_platform")) or "").lower()
bundle_id = _string_value(row.get("bundle_id")) or ""
Expand Down
4 changes: 2 additions & 2 deletions templates/app_versions.html
Original file line number Diff line number Diff line change
Expand Up @@ -174,15 +174,15 @@ <h2>App data is unavailable</h2>
{% for row in tab.rows %}
<tr>
<td>
<strong>{{ row.church }}</strong><br />
<strong>{{ "Demo" if row.church == "apollos_demo" else row.church }}</strong><br />
<span class="version-muted">{{ row.bundle_id }}</span>
</td>
<td>
{{ row.application_name }}<br />
<span class="version-muted version-app-detail">Observed {{ row.app_version or "unknown" }}</span>
{% if row.latest_app_version_source == "app_store" and row.latest_app_version != row.app_version %}
<br />
<span class="version-muted version-app-detail">App Store {{ row.latest_app_version }}</span>
<span class="version-muted version-app-detail">App Store (live) {{ row.latest_app_version }}</span>
{% endif %}
</td>
<td><code>{{ row.freshness_display or row.apollos_version or "TBD" }}</code></td>
Expand Down
158 changes: 116 additions & 42 deletions tests/test_app_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ def test_enriches_app_store_versions_by_bundle_id(self):
self.assertEqual(android["latest_app_version"], "1.0.0")
self.assertEqual(android["latest_app_version_source"], "observed")

def test_limits_app_store_lookup_count(self):
def test_looks_up_all_app_store_bundles(self):
rows = [
{
"church": f"church-{index}",
Expand All @@ -314,13 +314,10 @@ def test_limits_app_store_lookup_count(self):
app_versions._enrich_app_store_versions(rows)

lookup_bundle_ids = fetch_app_store_versions.call_args.args[0]
self.assertEqual(len(lookup_bundle_ids), app_versions.APP_STORE_LOOKUP_LIMIT)
self.assertNotIn(
f"com.example.{app_versions.APP_STORE_LOOKUP_LIMIT}",
lookup_bundle_ids,
)
self.assertEqual(len(lookup_bundle_ids), app_versions.APP_STORE_LOOKUP_LIMIT + 1)
self.assertIn(f"com.example.{app_versions.APP_STORE_LOOKUP_LIMIT}", lookup_bundle_ids)

def test_fetches_each_app_store_bundle_id_individually(self):
def test_batches_app_store_lookup_across_all_bundle_ids(self):
class Response:
def __init__(self, payload: dict[str, Any]):
self.payload = payload
Expand All @@ -332,48 +329,101 @@ def json(self) -> dict[str, Any]:
return self.payload

responses = [
Response(
{
"results": [
{
"bundleId": "com.example.one",
"version": "1.2.3",
},
],
}
),
Response(
{
"results": [
{
"bundleId": "com.example.two",
"version": "2.3.4",
},
],
}
),
Response({"results": [{"bundleId": "com.example.one", "version": "1.40"}]}),
Response({"results": [{"bundleId": "com.example.last", "version": "2.3.4"}]}),
]
bundle_ids = (
["com.example.one"]
+ [f"com.example.{i}" for i in range(app_versions.APP_STORE_LOOKUP_LIMIT - 1)]
+ ["com.example.last"]
)

with patch.object(
app_versions.requests,
"get",
side_effect=responses,
) as get:
versions = app_versions._fetch_app_store_versions(
["com.example.one", "com.example.two"]
)

self.assertEqual(versions["com.example.one"]["version"], "1.2.3")
self.assertEqual(versions["com.example.two"]["version"], "2.3.4")
def response_for_batch(url, *, params, timeout):
if "com.example.one" in params["bundleId"].split(","):
return responses[0]
return responses[1]

with patch.object(app_versions.requests, "get", side_effect=response_for_batch) as get:
versions = app_versions._fetch_app_store_versions(bundle_ids)
Comment thread
redreceipt marked this conversation as resolved.

self.assertEqual(versions["com.example.one"]["version"], "1.40")
self.assertEqual(versions["com.example.last"]["version"], "2.3.4")
self.assertEqual(get.call_count, 2)
self.assertCountEqual(
[call.kwargs["params"] for call in get.call_args_list],
[
{"bundleId": "com.example.one", "country": "us"},
{"bundleId": "com.example.two", "country": "us"},
{
"bundleId": ",".join(bundle_ids[: app_versions.APP_STORE_LOOKUP_LIMIT]),
"country": "us",
},
{"bundleId": "com.example.last", "country": "us"},
],
)

def test_failed_batch_does_not_hide_healthy_app_versions(self):
class Response:
def __init__(self, ids):
self.ids = ids

def raise_for_status(self):
pass

def json(self):
return {
"results": [
{"bundleId": bundle_id, "version": "1.40"} for bundle_id in self.ids
]
}

def lookup(url, *, params, timeout):
ids = params["bundleId"].split(",")
if "com.example.bad" in ids:
error = app_versions.requests.HTTPError("bad bundle")
error.response = types.SimpleNamespace(status_code=400)
raise error
return Response(ids)

bundle_ids = ["com.example.bad"] + [
f"com.example.good{i}" for i in range(app_versions.APP_STORE_LOOKUP_LIMIT)
]
with patch.object(app_versions.requests, "get", side_effect=lookup):
versions = app_versions._fetch_app_store_versions(bundle_ids)

self.assertEqual(len(versions), app_versions.APP_STORE_LOOKUP_LIMIT)
self.assertEqual(versions["com.example.good0"]["version"], "1.40")
self.assertIn(f"com.example.good{app_versions.APP_STORE_LOOKUP_LIMIT - 1}", versions)
self.assertNotIn("com.example.bad", versions)

def test_transient_or_malformed_batch_does_not_retry_every_bundle(self):
class Response:
def __init__(self, results):
self.results = results

def raise_for_status(self):
pass

def json(self):
return {"results": self.results}

bundle_ids = [f"com.example.{i}" for i in range(app_versions.APP_STORE_LOOKUP_LIMIT)]
bundle_ids.append("com.example.last")
for failure in ("timeout", "malformed"):
with self.subTest(failure=failure):

def lookup(url, *, params, timeout):
ids = params["bundleId"].split(",")
if len(ids) > 1:
if failure == "timeout":
raise app_versions.requests.RequestException("timeout")
return Response({"unexpected": "shape"})
return Response([{"bundleId": ids[0], "version": "1.40"}])

with patch.object(app_versions.requests, "get", side_effect=lookup) as get:
versions = app_versions._fetch_app_store_versions(bundle_ids)
self.assertEqual(get.call_count, 2)
self.assertEqual(versions["com.example.last"]["version"], "1.40")
self.assertEqual(len(versions), 1)

def test_selects_highest_observed_version_instead_of_most_recent_event(self):
rows = [
{
Expand Down Expand Up @@ -709,13 +759,37 @@ def test_apps_route_renders_platform_tabs(self):
self.assertIn("<th>Expo Runtime</th>", body)
self.assertNotIn("<th>Source</th>", body)
self.assertIn("1.0.1", body)
self.assertIn("App Store 1.0.1", body)
self.assertNotIn("App Store 1.0.0", body)
self.assertIn("App Store (live) 1.0.1", body)
self.assertNotIn("App Store (live) 1.0.0", body)
self.assertIn("<code>97</code>", body)
self.assertIn("Two Church", body)
self.assertNotIn("<th>Platform</th>", body)
self.assertNotIn("<th>Latest Observed</th>", body)

def test_preview_shows_demo_label_and_public_app_store_version(self):
row = {
"church": "apollos_demo",
"bundle_id": "com.differential.apollospreview",
"application_name": "Apollos Preview",
"app_version": "1.0.0",
"latest_app_version": "1.40",
"latest_app_version_source": "app_store",
"apollos_platform": "ios",
"freshness_display": "106",
}
context = {
"status": "ready",
"rows": [row],
"platform_tabs": app_versions.build_platform_tabs([row]),
"lookback_days": 30,
}
with patch.object(app_module, "get_app_versions_context", return_value=context):
body = self.client.get("/apps").get_data(as_text=True)
self.assertIn("<strong>Demo</strong>", body)
self.assertIn("Observed 1.0.0", body)
self.assertIn("App Store (live) 1.40", body)
self.assertNotIn("apollos_demo", body)


if __name__ == "__main__":
unittest.main()
Loading