From 996f87a02f0092a09259c03515171b03ba8c2de2 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 15 Oct 2026 22:21:51 +0000 Subject: [PATCH] Download the build named by the metadata The metadata response gives the MD5 of one database build. Requesting the download by date returns the latest build of that day, so a build published between the two requests causes a checksum mismatch. Request the download by the build_epoch from the metadata instead. The server always sends build_epoch, so the date request is gone. This ports maxmind/geoipupdate#566. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 3 +++ src/pygeoipupdate/client.py | 13 +++++-------- src/pygeoipupdate/models.py | 4 ++-- tests/test_cli.py | 4 ++++ tests/test_client.py | 11 +++++++++-- tests/test_updater.py | 28 +++++++++++++++++++++++++--- 6 files changed, 48 insertions(+), 15 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index e975e3e..c9d0a43 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -10,6 +10,9 @@ History ``aiohttp.encode_basic_auth()`` instead of the ``aiohttp.BasicAuth`` / ``auth=`` parameter, which are deprecated as of aiohttp 3.14.0. As a result, the minimum required ``aiohttp`` version is now 3.14.0. +* ``pygeoipupdate`` now identifies the database to download by its build + epoch rather than its date. Previously, if a new build was published on the + same day during an update, the download could fail with a checksum mismatch. 0.1.0 (2026-02-10) ++++++++++++++++++ diff --git a/src/pygeoipupdate/client.py b/src/pygeoipupdate/client.py index e245de7..524dd44 100644 --- a/src/pygeoipupdate/client.py +++ b/src/pygeoipupdate/client.py @@ -159,7 +159,7 @@ async def get_metadata(self, edition_id: str) -> Metadata: db = databases[0] return Metadata( edition_id=db["edition_id"], - date=db["date"], + build_epoch=db["build_epoch"], md5=db["md5"], ) except (KeyError, IndexError) as e: @@ -203,7 +203,7 @@ async def download( # Download the database compressed_path, last_modified = await self._fetch_database( - edition_id, metadata.date, temp_dir + edition_id, metadata.build_epoch, temp_dir ) return UpdateAvailable( @@ -215,14 +215,14 @@ async def download( async def _fetch_database( self, edition_id: str, - date: str, + build_epoch: int, temp_dir: Path, ) -> tuple[Path, datetime | None]: """Fetch the database file, streaming to a temp file. Args: edition_id: The database edition ID. - date: The database date in YYYY-MM-DD format. + build_epoch: The build to download, from the metadata response. temp_dir: Directory in which to create the download temp file. Returns: @@ -234,10 +234,7 @@ async def _fetch_database( msg = "Client must be used as async context manager" raise RuntimeError(msg) - # Convert date format from YYYY-MM-DD to YYYYMMDD - date_param = date.replace("-", "") - - params = urlencode({"date": date_param, "suffix": "tar.gz"}) + params = urlencode({"build_epoch": build_epoch, "suffix": "tar.gz"}) escaped_edition = quote(edition_id, safe="") url = f"{self._host}/geoip/databases/{escaped_edition}/download?{params}" diff --git a/src/pygeoipupdate/models.py b/src/pygeoipupdate/models.py index 20248b0..62dbf9c 100644 --- a/src/pygeoipupdate/models.py +++ b/src/pygeoipupdate/models.py @@ -13,13 +13,13 @@ class Metadata: Attributes: edition_id: The database edition ID. - date: The database date in YYYY-MM-DD format. + build_epoch: Identifies the exact build that md5 describes. md5: The MD5 hash of the database file. """ edition_id: str - date: str + build_epoch: int md5: str diff --git a/tests/test_cli.py b/tests/test_cli.py index 9bf6f08..961c3b8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -99,6 +99,7 @@ def test_successful_update(self, httpserver: HTTPServer, tmp_path: Path) -> None { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": mmdb_hash, } ] @@ -137,6 +138,7 @@ def test_verbose_output( { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": mmdb_hash, } ] @@ -177,6 +179,7 @@ def test_json_output(self, httpserver: HTTPServer, tmp_path: Path) -> None: { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": mmdb_hash, } ] @@ -262,6 +265,7 @@ def test_env_var_config(self, httpserver: HTTPServer, tmp_path: Path) -> None: { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": mmdb_hash, } ] diff --git a/tests/test_client.py b/tests/test_client.py index 230b888..7fa31b9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -30,6 +30,7 @@ async def test_get_metadata(self, httpserver: HTTPServer) -> None: { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": "abc123def456", } ] @@ -44,7 +45,7 @@ async def test_get_metadata(self, httpserver: HTTPServer) -> None: metadata = await client.get_metadata("GeoLite2-City") assert metadata.edition_id == "GeoLite2-City" - assert metadata.date == "2024-01-15" + assert metadata.build_epoch == 1705320000 assert metadata.md5 == "abc123def456" @pytest.mark.asyncio @@ -58,6 +59,7 @@ async def test_sends_authorization_header(self, httpserver: HTTPServer) -> None: { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": "abc123def456", } ] @@ -138,6 +140,7 @@ async def test_download_no_update_needed( { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": "current_hash", } ] @@ -169,6 +172,7 @@ async def test_download_with_update( { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": "new_hash", } ] @@ -177,7 +181,7 @@ async def test_download_with_update( httpserver.expect_request( "/geoip/databases/GeoLite2-City/download", - query_string="date=20240115&suffix=tar.gz", + query_string="build_epoch=1705320000&suffix=tar.gz", ).respond_with_data( tar_gz_data, content_type="application/gzip", @@ -208,6 +212,7 @@ async def test_download_auth_error( { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": "new_hash", } ] @@ -244,6 +249,7 @@ async def test_download_malformed_last_modified( { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": "new_hash", } ] @@ -314,6 +320,7 @@ async def test_download_http_500_on_fetch( { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": "new_hash", } ] diff --git a/tests/test_updater.py b/tests/test_updater.py index 15c57f0..de0051b 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -47,6 +47,7 @@ async def test_download_new_database( { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": mmdb_hash, } ] @@ -104,6 +105,7 @@ async def test_no_update_needed( { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": mmdb_hash, } ] @@ -158,6 +160,7 @@ async def test_multiple_editions( { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": city_hash, } ] @@ -177,6 +180,7 @@ async def test_multiple_editions( { "edition_id": "GeoLite2-Country", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": country_hash, } ] @@ -229,7 +233,12 @@ async def test_parallel_downloads( ).respond_with_json( { "databases": [ - {"edition_id": edition, "date": "2024-01-15", "md5": md5_hash} + { + "edition_id": edition, + "date": "2024-01-15", + "build_epoch": 1705320000, + "md5": md5_hash, + } ] } ) @@ -276,7 +285,12 @@ async def test_parallel_error_cancels_siblings( ).respond_with_json( { "databases": [ - {"edition_id": "Edition1", "date": "2024-01-15", "md5": e1_hash} + { + "edition_id": "Edition1", + "date": "2024-01-15", + "build_epoch": 1705320000, + "md5": e1_hash, + } ] } ) @@ -308,7 +322,12 @@ async def test_parallel_error_cancels_siblings( ).respond_with_json( { "databases": [ - {"edition_id": "Edition3", "date": "2024-01-15", "md5": e3_hash} + { + "edition_id": "Edition3", + "date": "2024-01-15", + "build_epoch": 1705320000, + "md5": e3_hash, + } ] } ) @@ -361,6 +380,7 @@ async def test_file_locking(self, httpserver: HTTPServer, tmp_path: Path) -> Non { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": mmdb_hash, } ] @@ -428,6 +448,7 @@ async def test_download_without_retry( { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": mmdb_hash, } ] @@ -483,6 +504,7 @@ def metadata_handler(_request: Request) -> Response: { "edition_id": "GeoLite2-City", "date": "2024-01-15", + "build_epoch": 1705320000, "md5": mmdb_hash, } ]