Compare commits

..
45 Commits
Author SHA1 Message Date
dsqlandClaude Code Opus 5.5 6ea3187b33 build_product_details: skip non-finite prices instead of raising OverflowError
PR #25 guarded calculate_priority but missed the same calculation here.
On 2026-10-09 06:25:50Z the consumer hit a product with an infinite price;
runner.py:201

    percentage = int(((original_price - current_price) / original_price) * 100)

raised `OverflowError: cannot convert float infinity to integer`. The
Discord leg dropped that one alert, but the API leg's caller does not
catch OverflowError, so the exception escaped and the consumer logged
CRITICAL "api deal loop stopped" -- no deals reached the API from then on
(nothing sent at all after 22:00Z), while the bot itself stayed "alive".

build_product_details now returns None (with a warning, like the
existing missing-price skip) when either price is a non-finite float, on
BOTH paths: the MSRP-less path never computes a percentage and would
otherwise carry inf into the Product and the API payload. A finite pair
whose ratio still overflows (a subnormal original price) is skipped too.
Ints and other types keep their existing behaviour.

Tests (tests/test_build_product_details.py): five new cases fail on the
old code -- OverflowError, ValueError for nan, and the MSRP-less case
building a Product with inf -- and all 25 pass now. Ruff findings on the
two files are unchanged from main (5, all pre-existing).

Rebuild every service that installs monitor-utils with --no-cache to pick
this up (it is pulled unpinned from main).

Co-Authored-By: Claude Code Opus 5.5 (ansible-ops, auto-repair) <noreply@anthropic.com>
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-10-09 23:22:36 -04:00
dsqlandClaude Code Opus 5.5 f51aed2c9f calculate_priority: resolve non-finite prices to 0 instead of crashing the monitor
tpv-academysports died on 2026-10-08 06:27 UTC and stayed down: six
identical crashes in 8 minutes (each ~2 min after start), then docker's
on-failure:5 gave up. Every one was

    database.py, in calculate_priority
        percentage = int(((original_price - current_price) / original_price) * 100)
    OverflowError: cannot convert float infinity to integer

A monitor builds prices with float() on the retailer's raw field, and
float() accepts "Infinity" / "1e999", so a non-finite price reaches here.
The `not original_price` guard catches None and 0, not inf or nan, and the
exception escapes add_products_to_database -> run_monitor_cycle and kills
the whole process -- one bad product takes the monitor down, and it
crashes again on every restart while that product is listed. Every
monitor that uses this lib has the same exposure.

calculate_priority now returns 0 (the existing "broken prices" bucket)
for a non-finite current/original price, and for a finite pair whose
ratio still overflows (a subnormal original price). Tests cover both;
they fail on the old code (OverflowError, plus ValueError for nan) and
pass now (tests/test_alert_priority.py: 10 passed).

Not changed: the monitor-side float() parsing, which could reject these
prices before they are stored -- worth a look by the monitor owners.

Co-Authored-By: Claude Code Opus 5.5 (ansible-ops, auto-repair) <noreply@anthropic.com>
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-10-08 18:46:59 -04:00
TPV 6720592685 small cleanup of comments/added optional impersonate into run_bounded_cycle for monitors like bhphoto that need different clients than what we support/dont need to rotate 2026-09-29 18:55:52 -07:00
TPV 4563cdf920 updated tls profiles 2026-09-26 21:11:37 -07:00
TPV b9fbb64364 added get_response_json function to return json from aioweb 2026-09-25 20:00:19 -07:00
TPV ae637a134f rename monitor_name parameters to monitor
Applies to setup_monitor_logging, init_monitor, setup_config, get_config,
add_products_to_database and build_alert. Breaking for callers passing
monitor_name by keyword; every monitor repo is updated alongside.
2026-09-25 19:02:09 -07:00
TPV 401258f52a renamed monitor_name to monitor 2026-09-25 17:29:08 -07:00
TPV 9a34ae5ca6 added asins to build_product_details 2026-09-10 06:38:44 -07:00
TPV 0275f94500 added price drops to alerts/fixed race condition where late claimed docs got their tags wiped and sent to the api without tags 2026-09-09 09:52:15 -07:00
TPV 03cc3a35cb added asins for woot listings 2026-09-08 20:14:49 -07:00
TPV 93013c5d55 updated the db name for dsql mongo 2026-08-30 17:59:05 -07:00
TPV 39a6c40154 updated the mongo_url to dsqls new url/small type hint fixes 2026-08-30 17:54:40 -07:00
TPV 1218ba0b3c added optional mongo client for already configured monitors 2026-08-21 08:21:57 -07:00
TPV b599d34e1e added optional mongo url 2026-08-21 07:52:21 -07:00
TPV b071ac35bf bug fix to avoid swallowing exceptions/failed pages inside fetch_all_pages/small refactor 2026-08-12 17:45:59 -07:00
TPV 321546f9b9 removed retry_failed_pages from docstrings 2026-08-08 18:59:52 -07:00
TPV 0b462aef06 removed failed page retrying 2026-08-08 13:49:35 -07:00
TPV dee090edaf added no msrp prirority so they don't starve in queue 2026-08-04 13:53:32 -07:00
TPV 6c309b1e62 use .get for has_msrp 2026-08-04 11:49:19 -07:00
TPV 1f5224a92c small typo fix 2026-08-04 10:07:31 -07:00
TPV 61c4887c38 added has_msrp for no msrp based products(new lists) 2026-08-04 10:04:32 -07:00
TPV 2e6e7ebc69 added passing headers 2026-07-30 14:29:58 -07:00
TPV 8903be2685 abstracted out bounded cycles 2026-07-30 14:13:26 -07:00
TPV aa6ed4cb06 added pagination fan out pagination abstraction 2026-07-30 11:52:36 -07:00
TPV 3f823b3a6e added price history to build_product_details/models 2026-07-28 11:29:15 -07:00
TPV 3573551957 removed past_prices 2026-07-28 10:38:33 -07:00
TPV 8a1c1893b7 added bounded close session for curl_cffi/past_prices to pricing model 2026-07-28 09:15:38 -07:00
TPV c29032a765 cleanup 2026-07-27 12:02:56 -07:00
TPV 1e53904cc6 added max_clients arg to get_default_session/cycle timeout seconds 2026-07-27 12:02:41 -07:00
TPV 99e68b1fa9 added first_error for asyncio task groups 2026-07-27 10:27:57 -07:00
TPV b6cd692258 added first_error for asyncio task groups 2026-07-27 10:23:07 -07:00
TPV 763f40988f added cycle timeout seconds 2026-07-27 08:49:59 -07:00
TPV d4f4830ff2 added motor/pinned the curl version 2026-07-27 08:48:04 -07:00
TPV 4c1033057a added combined discount for consumer so codes can be calculated on the fly for any monitor 2026-07-26 17:55:13 -07:00
TPV e9632470fd small refactor 2026-07-26 14:17:42 -07:00
TPV 5effb835a7 Merge pull request 'removed timeout from send_request and added promo options to product metadata' (#6) from aioweb-migration into main
Reviewed-on: #6
2026-07-26 21:09:51 +00:00
TPV b876c3d7b3 removed timeout from send_request and added promo options to product metadata 2026-07-26 14:09:05 -07:00
TPV 9af7e7adcf Merge pull request 'added optional semphore' (#5) from aioweb-migration into main
Reviewed-on: #5
2026-07-21 03:05:50 +00:00
TPV 4b63a2d39c added optional semphore 2026-07-20 19:27:47 -07:00
TPV 3cbe5beb43 Merge pull request 'removed user agent setting since curl_impersonate does it' (#4) from aioweb-migration into main
Reviewed-on: #4
2026-07-19 16:36:29 +00:00
TPV 0cfcca01bb removed user agent setting since curl_impersonate does it 2026-07-19 09:36:03 -07:00
TPV 65ddb40bc3 Merge pull request 'Aioweb migration' (#3) from aioweb-migration into main
Reviewed-on: #3
2026-07-19 16:29:00 +00:00
TPV db947a6354 raise default session timeout to 30s 2026-07-19 09:04:40 -07:00
TPV 666046bdd1 Merge pull request 'added ability to set default user agent' (#2) from aioweb-migration into main
Reviewed-on: #2
2026-07-19 04:33:03 +00:00
TPV e9559f3248 Merge pull request 'Aioweb migration' (#1) from aioweb-migration into main
Reviewed-on: #1
2026-07-19 01:26:35 +00:00
18 changed files with 1583 additions and 152 deletions

No files matched your search

+3 -1
View File
@@ -5,4 +5,6 @@ __pycache__
.idea .idea
monitor_utils.egg-info monitor_utils.egg-info
uv.lock uv.lock
.claude .claude/
build/
logs/
+3 -3
View File
@@ -5,12 +5,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "monitor-utils" name = "monitor-utils"
version = "0.1.0" version = "0.1.0"
requires-python = ">=3.10" requires-python = ">=3.11" # asyncio.timeout/TaskGroup, BaseExceptionGroup
dependencies = [ dependencies = [
"aiohttp>=3.14.1", "aiohttp>=3.14.1",
"aioproxies @ git+https://git.rethinkstudios.io/rethink-public/aioproxies.git", "aioproxies @ git+https://git.rethinkstudios.io/rethink-public/aioproxies.git",
"aioweb-tls[curl] @ git+https://git.rethinkstudios.io/rethink-public/aioweb_tls.git", "aioweb-tls[curl] @ git+https://git.rethinkstudios.io/rethink-public/aioweb_tls.git",
"curl-cffi>=0.15.0", "curl-cffi==0.15.0",
"fake-useragent>=2.2.0", "fake-useragent>=2.2.0",
"motor>=3.7.1", "motor>=3.7.1",
"pydantic>=2.13.4", "pydantic>=2.13.4",
@@ -36,7 +36,7 @@ testpaths = ["tests"]
[tool.ruff] [tool.ruff]
line-length = 88 line-length = 88
target-version = "py310" target-version = "py311"
# dsql_shortlinks.py is vendored third-party code; leave it untouched. # dsql_shortlinks.py is vendored third-party code; leave it untouched.
extend-exclude = ["src/monitor_utils/dsql_shortlinks.py"] extend-exclude = ["src/monitor_utils/dsql_shortlinks.py"]
+14 -4
View File
@@ -3,6 +3,8 @@
import logging import logging
from enum import Enum from enum import Enum
from motor.motor_asyncio import AsyncIOMotorClient
from monitor_utils.constants import LOGGERS from monitor_utils.constants import LOGGERS
from monitor_utils.database import get_collection from monitor_utils.database import get_collection
@@ -26,16 +28,22 @@ def is_production(development_mode: DevelopmentMode) -> bool:
return development_mode == DevelopmentMode.PRODUCTION return development_mode == DevelopmentMode.PRODUCTION
async def get_config(monitor_name: str, use_production: bool = False) -> dict: async def get_config(
monitor: str,
use_production: bool = False,
mongo_client: AsyncIOMotorClient = None,
) -> dict:
"""Load a monitor's config document from Mongo. """Load a monitor's config document from Mongo.
Reads the configs collection in production and configs_testing in Reads the configs collection in production and configs_testing in
development, so a dev run never reads production routing. development, so a dev run never reads production routing.
Args: Args:
monitor_name: Monitor identifier (the config document _id). monitor: Monitor identifier (the config document _id).
use_production: Read the production configs collection instead of use_production: Read the production configs collection instead of
configs_testing. configs_testing.
mongo_client: Client to read through; the shared cluster client when
None.
Returns: Returns:
The config document, including its categories and channels. The config document, including its categories and channels.
@@ -49,9 +57,11 @@ async def get_config(monitor_name: str, use_production: bool = False) -> dict:
if use_production if use_production
else f"{CONFIGS_COLLECTION_NAME}_testing" else f"{CONFIGS_COLLECTION_NAME}_testing"
) )
config = await get_collection(collection_name).find_one({"_id": monitor_name}) config = await get_collection(collection_name, mongo_client=mongo_client).find_one(
{"_id": monitor}
)
if config is None: if config is None:
raise ConfigNotFoundError(monitor_name) raise ConfigNotFoundError(monitor)
return config return config
+14 -5
View File
@@ -15,10 +15,8 @@ from aioweb import AiowebError
from curl_cffi.requests.errors import RequestsError from curl_cffi.requests.errors import RequestsError
from pymongo.errors import PyMongoError from pymongo.errors import PyMongoError
MONGO_DATABASE_NAME = "tpv_woot" MONGO_DATABASE_NAME = "tpv"
MONGO_URL = ( MONGO_URL = "mongodb://tpv:mvVuBhhUiCabeNikmUt71L8VqJQHh7IF@ridley.data.dev.ca.rethinkstudios.io:27017/tpv?authSource=admin"
"mongodb://tpv:n3%23QI07GnbZCbvGJ@zelda.disqualifier.me:27017/?authSource=tpv_woot"
)
LOGGING_LEVEL = "INFO" LOGGING_LEVEL = "INFO"
LOGGERS = [ LOGGERS = [
"discord", "discord",
@@ -34,7 +32,18 @@ MAX_BACKOFF_DELAY = 2.0
FAILURE_DELAY = 3 FAILURE_DELAY = 3
TIMEOUT_SECONDS = 30 TIMEOUT_SECONDS = 30
CLIENT_IDENTIFIERS = ["chrome", "chrome_android", "safari", "safari_ios"] CYCLE_TIMEOUT_SECONDS = 600
MAX_CURL_HANDLES = 64
SESSION_CLOSE_TIMEOUT_SECONDS = 10
CLIENT_IDENTIFIERS = [
"chrome119",
"chrome120",
"chrome124",
"chrome131",
"chrome131_android",
"safari",
"safari_ios",
]
CLIENT_TIMEOUT = aiohttp.ClientTimeout( CLIENT_TIMEOUT = aiohttp.ClientTimeout(
total=TIMEOUT_SECONDS, sock_connect=30, sock_read=30 total=TIMEOUT_SECONDS, sock_connect=30, sock_read=30
) )
+86 -18
View File
@@ -7,6 +7,7 @@ alert references for new products and price changes.
""" """
import logging import logging
import math
from dataclasses import asdict from dataclasses import asdict
from datetime import datetime, timezone from datetime import datetime, timezone
from functools import cache from functools import cache
@@ -20,18 +21,36 @@ from monitor_utils.models import Product
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
# Drain rank for products with no MSRP. Their percentage is 0 by construction, so
# without this they sort dead last and starve behind any backlog. 6 puts them
# directly under the 70%+ band, tying with 60-69% and broken by createdAt.
NO_MSRP_PRIORITY = 6
# Floor rank for new listings, which send regardless of discount and would
# otherwise sort near 0 and starve the same way MSRP-less products did.
NEW_LISTING_PRIORITY = 6
def get_collection(collection_name: str) -> AsyncIOMotorCollection:
def get_collection(
collection_name: str,
mongo_client: AsyncIOMotorClient = None,
database_name=MONGO_DATABASE_NAME,
) -> AsyncIOMotorCollection:
# rename mongo_client to client and add optional database name arg
"""Return the named collection from the shared client's database. """Return the named collection from the shared client's database.
Args: Args:
collection_name: Name of the collection to return. collection_name: Name of the collection to return.
mongo_client: optional client for projects with already configured client
database_name: optional name to use besides the default mongo database name
Returns: Returns:
The requested collection. The requested collection.
""" """
return get_mongo_client()[MONGO_DATABASE_NAME][collection_name] if mongo_client is None:
mongo_client = get_mongo_client()
return mongo_client[database_name][collection_name]
def resolve_alerts_collection( def resolve_alerts_collection(
@@ -58,17 +77,22 @@ def resolve_alerts_collection(
@cache @cache
def get_mongo_client() -> AsyncIOMotorClient: def get_mongo_client(mongo_url: str = MONGO_URL) -> AsyncIOMotorClient:
"""Return a cached Motor client configured for the shared cluster. """Return a cached Motor client configured for the shared cluster.
Args:
mongo_url: The mongo url to use for connections.
Returns: Returns:
The pooled, timezone-aware Motor client. The pooled, timezone-aware Motor client.
""" """
return AsyncIOMotorClient( return AsyncIOMotorClient(
MONGO_URL, mongo_url,
maxPoolSize=200,
minPoolSize=10, minPoolSize=10,
connectTimeoutMS=20_000,
socketTimeoutMS=60_000,
serverSelectionTimeoutMS=30_000,
retryWrites=True, retryWrites=True,
retryReads=True, retryReads=True,
tz_aware=True, tz_aware=True,
@@ -89,7 +113,7 @@ async def add_products_to_database(
products: list[Product], products: list[Product],
collection: AsyncIOMotorCollection, collection: AsyncIOMotorCollection,
category_identifier: str, category_identifier: str,
monitor_name: str, monitor: str,
use_composite: bool = False, use_composite: bool = False,
): ):
"""Upsert products, tracking deduplicated price history and emitting alerts. """Upsert products, tracking deduplicated price history and emitting alerts.
@@ -97,7 +121,9 @@ async def add_products_to_database(
Rewrites current fields on each upsert. priceBeforeDrop is derived here, Rewrites current fields on each upsert. priceBeforeDrop is derived here,
not by monitors: it is set to the previous stored currentPrice when the new not by monitors: it is set to the previous stored currentPrice when the new
price is lower (a drop) and cleared otherwise, so any price_before_drop on price is lower (a drop) and cleared otherwise, so any price_before_drop on
the incoming product is ignored. Prices are recorded in a pastPrices array the incoming product is ignored. newListing is likewise stamped here, not
by monitors: True only on the cycle whose write inserts the document, and
copied onto the discovery alert, whose copy never flips. Prices are recorded in a pastPrices array
of price/date entries; a price already present in the array is never of price/date entries; a price already present in the array is never
recorded again, so each distinct price keeps only its first-seen timestamp. recorded again, so each distinct price keeps only its first-seen timestamp.
New products and never-before-recorded prices New products and never-before-recorded prices
@@ -113,7 +139,7 @@ async def add_products_to_database(
products: Products to upsert. products: Products to upsert.
collection: Target MongoDB collection. collection: Target MongoDB collection.
category_identifier: Category suffix appended to composite ids. category_identifier: Category suffix appended to composite ids.
monitor_name: Monitor name stamped onto emitted alerts for consumer monitor: Monitor name stamped onto emitted alerts for consumer
routing. routing.
use_composite: Build composite ids so multi-variant monitors (Woot) use_composite: Build composite ids so multi-variant monitors (Woot)
keep one doc per variant; other monitors collapse to one doc per keep one doc per variant; other monitors collapse to one doc per
@@ -141,6 +167,10 @@ async def add_products_to_database(
document = convert_product_to_mongo(product) document = convert_product_to_mongo(product)
document["_id"] = doc_id document["_id"] = doc_id
existing_doc = existing.get(doc_id) existing_doc = existing.get(doc_id)
# Stamped here, not by monitors: only the database knows whether the
# doc already exists. Must precede build_alert so the alert's copy of
# the flag is taken from the stamped value.
document["newListing"] = existing_doc is None
new_price = document.get("currentPrice") new_price = document.get("currentPrice")
previous_price = existing_doc.get("currentPrice") if existing_doc else None previous_price = existing_doc.get("currentPrice") if existing_doc else None
document["priceBeforeDrop"] = resolve_price_before_drop( document["priceBeforeDrop"] = resolve_price_before_drop(
@@ -151,7 +181,7 @@ async def add_products_to_database(
never_seen_price = new_price is not None and new_price not in recorded_prices never_seen_price = new_price is not None and new_price not in recorded_prices
set_on_insert = {"discoveredDate": now} set_on_insert = {"discoveredDate": now}
if existing_doc is None or never_seen_price: if existing_doc is None or never_seen_price:
alert = build_alert(monitor_name, document, now) alert = build_alert(monitor, document, now)
alert_ops.append( alert_ops.append(
UpdateOne( UpdateOne(
{"product_id": doc_id, "claimed": False}, {"product_id": doc_id, "claimed": False},
@@ -250,14 +280,22 @@ def extract_recorded_prices(existing_doc: dict | None) -> set[float]:
return {entry["price"] for entry in existing_doc.get("pastPrices", [])} return {entry["price"] for entry in existing_doc.get("pastPrices", [])}
def build_alert(monitor_name: str, document: dict, now: datetime) -> dict: def build_alert(monitor: str, document: dict, now: datetime) -> dict:
"""Build an alert referencing a product for consumer delivery. """Build an alert referencing a product for consumer delivery.
The alert carries its own copy of the document's newListing flag and
priceBeforeDrop: the product doc is rewritten every cycle, so its flag
flips False one cycle after discovery and its priceBeforeDrop is
cleared by the next cycle that sees no drop, while an alert may sit in
a backed-up queue longer than that. The alert's copies are written once
and cannot rot.
Args: Args:
monitor_name: Monitor that produced the product, used by the monitor: Monitor that produced the product, used by the
consumer to resolve the owning collection and thresholds. consumer to resolve the owning collection and thresholds.
document: Product document supplying the id and pricing for the document: Product document supplying the id, pricing for the
priority calculation. priority calculation, and the stamped newListing flag and
priceBeforeDrop.
now: Detection timestamp stored as createdAt. now: Detection timestamp stored as createdAt.
Returns: Returns:
@@ -265,12 +303,14 @@ def build_alert(monitor_name: str, document: dict, now: datetime) -> dict:
""" """
return { return {
"monitor": monitor_name, "monitor": monitor,
"product_id": document["_id"], "product_id": document["_id"],
"priority": calculate_priority(document), "priority": calculate_priority(document),
"claimed": False, "claimed": False,
"attempts": 0, "attempts": 0,
"createdAt": now, "createdAt": now,
"newListing": document.get("newListing", False),
"priceBeforeDrop": document.get("priceBeforeDrop"),
} }
@@ -279,8 +319,15 @@ def calculate_priority(document: dict) -> int:
Always recalculated from current and original price so the priority Always recalculated from current and original price so the priority
reflects the prices at alert time rather than any stale stored reflects the prices at alert time rather than any stale stored
percentage. Missing prices, zero original prices, and price increases percentage. Missing prices, zero original prices, non-finite prices, a
resolve to priority 0. discount too large to represent, and price increases resolve to priority
0, so one product's bad pricing can never raise out of the whole cycle (a
retailer field like "Infinity" survives float() and used to crash the
monitor with an OverflowError). A product with no MSRP has no percentage
to rank on and takes NO_MSRP_PRIORITY instead, since a real 0 would leave
it permanently behind every other alert in the claim query's sort. A new
listing is floored at NEW_LISTING_PRIORITY for the same reason: it may
carry any discount at all, and its percentage rank still wins when higher.
Args: Args:
document: Product document supplying the prices. document: Product document supplying the prices.
@@ -294,8 +341,22 @@ def calculate_priority(document: dict) -> int:
if current_price is None or not original_price: if current_price is None or not original_price:
return 0 return 0
percentage = int(((original_price - current_price) / original_price) * 100) if not (math.isfinite(current_price) and math.isfinite(original_price)):
return min(max(percentage // 10, 0), 10) return 0
if not document.get("hasMsrp", True):
return NO_MSRP_PRIORITY
discount = ((original_price - current_price) / original_price) * 100
if not math.isfinite(discount):
return 0
percentage = int(discount)
priority = min(max(percentage // 10, 0), 10)
if document.get("newListing"):
return max(priority, NEW_LISTING_PRIORITY)
return priority
def build_doc_id( def build_doc_id(
@@ -347,10 +408,17 @@ def convert_product_to_mongo(product: Product) -> dict:
"customizationIndex": metadata["customization_index"], "customizationIndex": metadata["customization_index"],
"memberNonMemberComparison": metadata["member_non_member_comparison"], "memberNonMemberComparison": metadata["member_non_member_comparison"],
"overstockText": metadata["overstock_text"], "overstockText": metadata["overstock_text"],
"affiliateUrl": metadata["affiliate_url"],
"promoCode": metadata["promo_code"],
"promoPercentage": metadata["promo_percentage"],
"promoDollarOff": metadata["promo_dollar_off"],
"promoMinSpend": metadata["promo_min_spend"],
"asin": metadata["asin"],
"startDate": timing["start_date"], "startDate": timing["start_date"],
"endDate": timing["end_date"], "endDate": timing["end_date"],
"priceBeforeDrop": pricing["price_before_drop"], "priceBeforeDrop": pricing["price_before_drop"],
"eduPrice": pricing["edu_price"], "eduPrice": pricing["edu_price"],
"hasMsrp": pricing["has_msrp"],
"available": availability["available"], "available": availability["available"],
"availabilityLastChecked": availability["availability_last_checked"], "availabilityLastChecked": availability["availability_last_checked"],
"condition": availability["condition"], "condition": availability["condition"],
+21 -2
View File
@@ -29,7 +29,7 @@ class ConsumerConfig:
"""Configuration for a single retailer's consumer. """Configuration for a single retailer's consumer.
Attributes: Attributes:
monitor_name: Retailer name (matches setup_config key). monitor: Retailer name.
collection: MongoDB collection for this retailer's products. collection: MongoDB collection for this retailer's products.
channel_ids: Discord webhook/channel routing config. channel_ids: Discord webhook/channel routing config.
min_percentage: Minimum discount % to trigger a ping. min_percentage: Minimum discount % to trigger a ping.
@@ -38,7 +38,7 @@ class ConsumerConfig:
the secondary rule. the secondary rule.
""" """
monitor_name: str monitor: str
collection: object collection: object
channel_ids: dict channel_ids: dict
min_percentage: int min_percentage: int
@@ -54,12 +54,15 @@ class PricingInfo:
current_price: Current listed price. current_price: Current listed price.
price_before_drop: Stored price prior to the last detected drop. price_before_drop: Stored price prior to the last detected drop.
edu_price: Education-store price when offered. edu_price: Education-store price when offered.
past_prices: Prior price history as price/date entries, oldest first.
""" """
original_price: float original_price: float
current_price: float current_price: float
price_before_drop: float | None = None price_before_drop: float | None = None
edu_price: float | None = None edu_price: float | None = None
past_prices: list[dict] | None = None
has_msrp: bool = True
@dataclass @dataclass
@@ -97,6 +100,16 @@ class ProductMetadata:
overstock_text: Overstock label text when present. overstock_text: Overstock label text when present.
percentage: Computed discount percentage. percentage: Computed discount percentage.
affiliate_url: Affiliate link when available. affiliate_url: Affiliate link when available.
promo_code: Event-level checkout code (e.g. "BIRTHDAY26") when the
product's event advertises one, else None.
promo_percentage: Advertised percent off from a percentage code
("extra 22% off" -> 22); None for dollar codes or no code. This is
the claimed discount, not the actual price delta.
promo_dollar_off: Advertised dollar amount off from a spend-threshold
code ("get an extra $22 off" -> 22); None for percentage codes or
no code. Pairs with promo_min_spend.
promo_min_spend: Spend threshold a dollar code requires ("Spend $100"
-> 100); None unless promo_dollar_off is set.
""" """
image_url: str | None = None image_url: str | None = None
@@ -106,6 +119,12 @@ class ProductMetadata:
overstock_text: str | None = None overstock_text: str | None = None
percentage: int | None = None percentage: int | None = None
affiliate_url: str | None = None affiliate_url: str | None = None
promo_code: str | None = None
promo_percentage: int | None = None
promo_dollar_off: int | None = None
promo_min_spend: int | None = None
new_listing: bool | None = None
asin: str | None = None
@dataclass @dataclass
+244 -105
View File
@@ -1,12 +1,16 @@
import asyncio import asyncio
import logging import logging
import math
from collections.abc import Awaitable, Callable, Iterable from collections.abc import Awaitable, Callable, Iterable
from contextlib import asynccontextmanager, nullcontext
import aiohttp import aiohttp
from aioproxies import AioProxies from aioproxies import AioProxies
from curl_cffi.requests import AsyncSession from curl_cffi.requests import AsyncSession
from curl_cffi.requests.impersonate import BrowserTypeLiteral
from log_setup import setup_logging from log_setup import setup_logging
from motor.motor_asyncio import AsyncIOMotorCollection from motor.motor_asyncio import AsyncIOMotorCollection
from motor.motor_asyncio import AsyncIOMotorClient
from monitor_utils.config import ( from monitor_utils.config import (
DevelopmentMode, DevelopmentMode,
@@ -14,8 +18,15 @@ from monitor_utils.config import (
is_production, is_production,
set_logger_levels, set_logger_levels,
) )
from monitor_utils.constants import EXPECTED_TASK_ERRORS, LOGGING_LEVEL from monitor_utils.constants import (
CYCLE_TIMEOUT_SECONDS,
EXPECTED_TASK_ERRORS,
LOGGING_LEVEL,
MAX_CURL_HANDLES,
TIMEOUT_SECONDS,
)
from monitor_utils.database import get_collection from monitor_utils.database import get_collection
from monitor_utils.sessions import get_default_session
from monitor_utils.models import ( from monitor_utils.models import (
AvailabilityInfo, AvailabilityInfo,
CycleResult, CycleResult,
@@ -28,22 +39,22 @@ from monitor_utils.models import (
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def setup_monitor_logging(monitor_name: str, logging_level=LOGGING_LEVEL): def setup_monitor_logging(monitor: str, logging_level=LOGGING_LEVEL):
"""Configure JSON logging for a monitor and quiet noisy libraries. """Configure JSON logging for a monitor and quiet noisy libraries.
Call once at module import, at global scope, before creating module loggers, Call once at module import, at global scope, before creating module loggers,
so logging is configured synchronously at startup rather than inside the so logging is configured synchronously at startup rather than inside the
async init_monitor. Logs go to logs/latest.log with rotated history keyed by async init_monitor. Logs go to logs/latest.log with rotated history keyed by
monitor_name. monitor.
Args: Args:
monitor_name: Monitor name used as the rotated-history base. monitor: Monitor name used as the rotated-history base.
logging_level: Root logging level. logging_level: Root logging level.
""" """
setup_logging( setup_logging(
name="logs/latest.log", name="logs/latest.log",
history_name=monitor_name, history_name=monitor,
level=logging_level, level=logging_level,
output="json", output="json",
) )
@@ -51,7 +62,7 @@ def setup_monitor_logging(monitor_name: str, logging_level=LOGGING_LEVEL):
async def init_monitor( async def init_monitor(
monitor_name: str, monitor: str,
development_mode: DevelopmentMode, development_mode: DevelopmentMode,
production_proxy_path: str = "proxies.txt", production_proxy_path: str = "proxies.txt",
testing_proxy_path: str = "tests/configs/proxies.txt", testing_proxy_path: str = "tests/configs/proxies.txt",
@@ -63,7 +74,7 @@ async def init_monitor(
by setup_monitor_logging at module scope and is not touched here. by setup_monitor_logging at module scope and is not touched here.
Args: Args:
monitor_name: Monitor identifier used for config and collection lookup. monitor: Monitor identifier used for config and collection lookup.
development_mode: Selects production or testing config, collection, development_mode: Selects production or testing config, collection,
and proxy path. and proxy path.
production_proxy_path: Proxy file path used in production mode. production_proxy_path: Proxy file path used in production mode.
@@ -75,7 +86,7 @@ async def init_monitor(
use_production = is_production(development_mode) use_production = is_production(development_mode)
category_names, _, collection = await setup_config( category_names, _, collection = await setup_config(
monitor_name=monitor_name, development_mode=development_mode monitor=monitor, development_mode=development_mode
) )
proxy_path = production_proxy_path if use_production else testing_proxy_path proxy_path = production_proxy_path if use_production else testing_proxy_path
proxies = AioProxies.from_file(path=proxy_path) proxies = AioProxies.from_file(path=proxy_path)
@@ -83,7 +94,9 @@ async def init_monitor(
async def setup_config( async def setup_config(
monitor_name: str, development_mode: DevelopmentMode monitor: str,
development_mode: DevelopmentMode,
mongo_client: AsyncIOMotorClient = None,
) -> tuple[ ) -> tuple[
list[str] | dict[str, str], dict[str, str | list[str]], AsyncIOMotorCollection list[str] | dict[str, str], dict[str, str | list[str]], AsyncIOMotorCollection
]: ]:
@@ -94,9 +107,11 @@ async def setup_config(
during development. during development.
Args: Args:
monitor_name: Monitor identifier used for config and collection lookup. monitor: Monitor identifier used for config and collection lookup.
development_mode: Selects the production or testing config and development_mode: Selects the production or testing config and
collection. collection.
mongo_client: Client the config read and the returned collection use;
the shared cluster client when None.
Returns: Returns:
Tuple of category names, channel id mapping, and Mongo collection. Tuple of category names, channel id mapping, and Mongo collection.
@@ -104,32 +119,65 @@ async def setup_config(
use_production = is_production(development_mode) use_production = is_production(development_mode)
config = await get_config( config = await get_config(
monitor_name=monitor_name, monitor=monitor,
use_production=use_production, use_production=use_production,
mongo_client=mongo_client,
) )
category_names = config["categories"] category_names = config["categories"]
collection = get_collection( collection = get_collection(
monitor_name if use_production else f"{monitor_name}_testing" monitor if use_production else f"{monitor}_testing",
mongo_client=mongo_client,
) )
return category_names, config["channels"], collection return category_names, config["channels"], collection
def combined_discount(base_percentage: int, promo_percentage: int | None) -> int:
"""Combine an on-sale discount with a percentage code applied at checkout.
A "extra N% off" code discounts the already-reduced sale price, so the two
compound rather than add: 20% off then an extra 30% code is 44% off list,
not 50%. Returns base_percentage unchanged when there is no percentage code.
Args:
base_percentage: The on-sale discount off list, as a whole percent.
promo_percentage: The code's advertised percent off, or None.
Returns:
The combined discount off list, rounded to a whole percent.
"""
if not promo_percentage:
return base_percentage
return round(100 - (100 - base_percentage) * (100 - promo_percentage) / 100)
def build_product_details( def build_product_details(
product: dict, product: dict,
min_percentage: int, min_percentage: int,
min_price_drop: float | None = None, min_price_drop: float | None = None,
bypass_thresholds: bool = False,
) -> Product | None: ) -> Product | None:
"""Build a Product from a Mongo document if it passes discount thresholds. """Build a Product from a Mongo document if it passes discount thresholds.
Skips documents with a missing or zero original price. Passing requires Skips documents with a missing or zero original price, a non-finite
meeting min_percentage, or min_price_drop when provided. Channel routing price, or a discount too large to represent: a retailer field like
is owned by the consumer bot; no channel id is attached here. "Infinity" survives float(), and the percentage math used to raise
OverflowError out of every caller. Passing requires
meeting min_percentage, or min_price_drop when provided. The caller may
bypass both thresholds (new listings send at any discount for monitors
that opt in); which alerts qualify is the caller's decision, not read
from the document, so per-monitor rules stay out of this function.
Channel routing is owned by the consumer bot; no channel id is attached
here.
Args: Args:
product: Mongo document for the product. product: Mongo document for the product.
min_percentage: Minimum discount percentage to pass. min_percentage: Minimum discount percentage to pass.
min_price_drop: Optional minimum absolute price drop to pass. min_price_drop: Optional minimum absolute price drop to pass.
bypass_thresholds: Build the product even when it fails both
thresholds. The missing- and non-finite-price skips still apply.
Returns: Returns:
The built Product, or None when skipped or below thresholds. The built Product, or None when skipped or below thresholds.
@@ -138,6 +186,8 @@ def build_product_details(
product_id = product["_id"] product_id = product["_id"]
current_price = product["currentPrice"] current_price = product["currentPrice"]
original_price = product["originalPrice"] original_price = product["originalPrice"]
new_listing = product.get("newListing")
if not original_price: if not original_price:
LOGGER.warning( LOGGER.warning(
"missing or zero original price, skipping", "missing or zero original price, skipping",
@@ -145,14 +195,49 @@ def build_product_details(
) )
return return
percentage = int(((original_price - current_price) / original_price) * 100) # Skipped on BOTH paths, not just the percentage one: an MSRP-less product
price_drop = original_price - current_price # would otherwise carry the infinite price into its payload. Only floats can
passes_percentage = percentage >= min_percentage # be non-finite, so ints and other types keep their existing behaviour.
passes_price_drop = min_price_drop is not None and price_drop >= min_price_drop if any(
if not passes_percentage and not passes_price_drop: isinstance(price, float) and not math.isfinite(price)
for price in (current_price, original_price)
):
LOGGER.warning(
"non-finite price, skipping",
extra={"product_id": product_id},
)
return return
built = Product( percentage = 0
# Defaulted, not indexed: documents written before the field existed have no
# hasMsrp at all, and stale ones that drop out of a monitor's feed are never
# re-upserted, so they would KeyError here forever. True keeps them on the
# pre-existing threshold path rather than silently routing them as MSRP-less.
has_msrp = product.get("hasMsrp", True)
if has_msrp:
discount = ((original_price - current_price) / original_price) * 100
if not math.isfinite(discount):
LOGGER.warning(
"discount not representable, skipping",
extra={"product_id": product_id},
)
return
percentage = int(discount)
price_drop = original_price - current_price
# A percentage code compounds onto the sale price, so an item can clear the
# threshold on the combined discount even when the base discount would not.
# Dollar codes do not map to a percent and never affect the threshold.
effective_percentage = combined_discount(
percentage, product.get("promoPercentage")
)
passes_percentage = effective_percentage >= min_percentage
passes_price_drop = min_price_drop is not None and price_drop >= min_price_drop
if not bypass_thresholds and not passes_percentage and not passes_price_drop:
return
built_product = Product(
product_id=product_id, product_id=product_id,
title=product["title"], title=product["title"],
url=product["url"], url=product["url"],
@@ -163,6 +248,12 @@ def build_product_details(
overstock_text=product["overstockText"], overstock_text=product["overstockText"],
percentage=percentage, percentage=percentage,
affiliate_url=product.get("affiliateUrl"), affiliate_url=product.get("affiliateUrl"),
promo_code=product.get("promoCode"),
promo_percentage=product.get("promoPercentage"),
promo_dollar_off=product.get("promoDollarOff"),
promo_min_spend=product.get("promoMinSpend"),
new_listing=new_listing,
asin=product.get("asin"),
), ),
timing=ProductTiming( timing=ProductTiming(
discovered_date=product["discoveredDate"], discovered_date=product["discoveredDate"],
@@ -174,6 +265,8 @@ def build_product_details(
original_price=original_price, original_price=original_price,
price_before_drop=product["priceBeforeDrop"], price_before_drop=product["priceBeforeDrop"],
edu_price=product["eduPrice"], edu_price=product["eduPrice"],
past_prices=product.get("pastPrices"),
has_msrp=has_msrp,
), ),
availability=AvailabilityInfo( availability=AvailabilityInfo(
available=product["available"], available=product["available"],
@@ -183,7 +276,7 @@ def build_product_details(
member_deal=product["memberDeal"], member_deal=product["memberDeal"],
), ),
) )
return built return built_product
async def run_monitor_cycle( async def run_monitor_cycle(
@@ -202,8 +295,10 @@ async def run_monitor_cycle(
concurrently, logs expected failures, and raises unexpected ones. The concurrently, logs expected failures, and raises unexpected ones. The
caller owns the loop, the session lifetime, and the inter-cycle delay: wrap caller owns the loop, the session lifetime, and the inter-cycle delay: wrap
this in a `while True` that opens a fresh session per cycle and sleeps this in a `while True` that opens a fresh session per cycle and sleeps
between cycles. The returned CycleResult is pure counts for the caller to between cycles — or use run_bounded_cycle, which owns the session and a
log or act on; this function applies no policy to it. hard cycle budget and leaves only the loop and the sleep to the caller.
The returned CycleResult is pure counts for the caller to log or act on;
this function applies no policy to it.
session and collection are the two per-cycle resources, passed to every session and collection are the two per-cycle resources, passed to every
callback (pre_task_func, concurrent_pre_task_func, and each task_func) just callback (pre_task_func, concurrent_pre_task_func, and each task_func) just
@@ -298,6 +393,72 @@ async def run_monitor_cycle(
return CycleResult(successes=successes, failures=failures) return CycleResult(successes=successes, failures=failures)
async def run_bounded_cycle(
category_identifiers: list[str] | dict[str, str],
task_func: Callable[..., Awaitable[None]],
proxies: AioProxies,
collection: AsyncIOMotorCollection,
*,
headers: dict | None = None,
session_timeout: int = TIMEOUT_SECONDS,
max_clients: int = MAX_CURL_HANDLES,
cycle_timeout: int = CYCLE_TIMEOUT_SECONDS,
impersonate: BrowserTypeLiteral | None = None,
**kwargs,
) -> CycleResult | None:
"""Run one monitor cycle on a fresh session under a hard time budget.
The session is opened and closed inside the call, so curl multi-handle
state cannot outlive one cycle: a wedged transfer dies with its session
instead of parking the process. The budget turns an engine-level lost
wakeup into a caught TimeoutError and an abandoned cycle, never a
permanent wedge; the session's own close is bounded by
get_default_session, so the exit cannot park past the budget either.
The caller still owns the loop and the inter-cycle delay:
while True:
await run_bounded_cycle(ids, task_func, proxies, collection)
await asyncio.sleep(CYCLE_DELAY)
Args:
category_identifiers: Categories, passed through to run_monitor_cycle.
task_func: Awaitable invoked per category.
proxies: Pool bound to each cycle's fresh session.
collection: Mongo collection forwarded to every callback.
headers: Monitor-wide static headers merged into every request.
session_timeout: Per-request timeout on the cycle's session.
max_clients: Curl handle pool size. Keep at or above the monitor's
whole fan-out; see get_default_session for why.
cycle_timeout: Hard budget in seconds for the whole cycle.
**kwargs: Forwarded to run_monitor_cycle (pre_task_func,
concurrent_pre_task_func, and per-task kwargs).
Returns:
The cycle's CycleResult, or None when the budget expired and the
cycle was abandoned.
"""
async with get_default_session(
proxies, headers, timeout=session_timeout, max_clients=max_clients
) as session:
try:
async with asyncio.timeout(cycle_timeout):
return await run_monitor_cycle(
category_identifiers,
task_func,
session=session,
collection=collection,
**kwargs,
)
except TimeoutError:
LOGGER.error(
"cycle exceeded budget, abandoning cycle",
extra={"timeout": cycle_timeout},
)
return None
async def execute_pagination( async def execute_pagination(
category_identifier: str, category_identifier: str,
total_products: int, total_products: int,
@@ -305,7 +466,7 @@ async def execute_pagination(
func: Callable[..., Awaitable[None]], func: Callable[..., Awaitable[None]],
session: aiohttp.ClientSession | AsyncSession, session: aiohttp.ClientSession | AsyncSession,
*, *,
semaphore: asyncio.Semaphore, semaphore: asyncio.Semaphore | None = None,
start_offset: int = 0, start_offset: int = 0,
pagination_mode: str = "offset", pagination_mode: str = "offset",
**kwargs, **kwargs,
@@ -323,10 +484,12 @@ async def execute_pagination(
func: Awaitable task invoked per page; receives session plus either func: Awaitable task invoked per page; receives session plus either
offset or page_number depending on pagination_mode. offset or page_number depending on pagination_mode.
session: HTTP session forwarded to each func call. session: HTTP session forwarded to each func call.
semaphore: Required bound on concurrent page fetches. Pass one shared semaphore: Optional bound on concurrent page fetches. Omit it to let a
semaphore across all categories so the limit caps total fan-out; a monitor with few pages fan out unbounded; pass one to cap monitors
per-call semaphore would instead bound each category in isolation, with many pages. Pass a single shared semaphore across all categories
making the effective limit this value times the category count. so the limit caps total fan-out; a per-call semaphore would instead
bound each category in isolation, making the effective limit this
value times the category count.
start_offset: Additive base applied to every offset, for APIs whose start_offset: Additive base applied to every offset, for APIs whose
offset space does not begin at zero. Page N requests offset offset space does not begin at zero. Page N requests offset
start_offset + (N - 1) * products_per_page. start_offset + (N - 1) * products_per_page.
@@ -342,16 +505,12 @@ async def execute_pagination(
"executing pagination", "executing pagination",
extra={"category": category_identifier, "total": total_pages}, extra={"category": category_identifier, "total": total_pages},
) )
stop_event = asyncio.Event()
pagination_label = "offset" if pagination_mode == "offset" else "page" pagination_label = "offset" if pagination_mode == "offset" else "page"
page_range = range(2, total_pages + 1) page_range = range(2, total_pages + 1)
async def fetch_page(page_index: int): async def fetch_page(page_index: int):
"""Fetch a single page or offset under the shared semaphore.""" """Fetch a single page or offset, optionally under the shared semaphore."""
async with semaphore: async with semaphore or nullcontext():
if stop_event.is_set():
return
if pagination_mode == "offset": if pagination_mode == "offset":
current_offset = start_offset + (page_index - 1) * products_per_page current_offset = start_offset + (page_index - 1) * products_per_page
await func( await func(
@@ -369,14 +528,9 @@ async def execute_pagination(
failed_pages = await fetch_all_pages( failed_pages = await fetch_all_pages(
page_range, fetch_page, category_identifier, pagination_label page_range, fetch_page, category_identifier, pagination_label
) )
if failed_pages and not stop_event.is_set(): if failed_pages:
failed_pages = await retry_failed_pages(
failed_pages, fetch_page, category_identifier, pagination_label
)
if failed_pages and not stop_event.is_set():
LOGGER.error( LOGGER.error(
"failed to get products after retries", "failed to get products from failed pages",
extra={ extra={
"category": category_identifier, "category": category_identifier,
"label": pagination_label, "label": pagination_label,
@@ -384,9 +538,6 @@ async def execute_pagination(
}, },
) )
if stop_event.is_set():
LOGGER.info("pagination stopped early", extra={"category": category_identifier})
async def fetch_all_pages( async def fetch_all_pages(
page_range: Iterable[int], page_range: Iterable[int],
@@ -406,11 +557,11 @@ async def fetch_all_pages(
Page numbers whose fetch raised an expected error. Page numbers whose fetch raised an expected error.
""" """
tasks = [fetch_func(page) for page in page_range] # list once: a one-shot iterable would be exhausted before the result pairing
pages = list(page_range)
tasks = [fetch_func(page) for page in pages]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
return handle_page_results( return handle_page_results(pages, results, category_identifier, pagination_label)
list(page_range), results, category_identifier, pagination_label
)
def handle_page_results( def handle_page_results(
@@ -418,20 +569,17 @@ def handle_page_results(
results: list, results: list,
category_identifier: str, category_identifier: str,
pagination_label: str, pagination_label: str,
retry_attempt: int | None = None,
) -> list[int]: ) -> list[int]:
"""Log page results and collect failures, raising on unexpected errors. """Log page results and collect failures, raising on unexpected errors.
Expected errors are logged as warnings and their pages collected for Expected errors are logged as warnings and their pages collected and
retry. Unexpected exceptions are logged and re-raised. Successful pages returned. Unexpected exceptions are logged and re-raised.
are logged only when part of a retry attempt.
Args: Args:
page_numbers: Page numbers matching results by position. page_numbers: Page numbers matching results by position.
results: Gathered results or exceptions per page. results: Gathered results or exceptions per page.
category_identifier: Label used for logging. category_identifier: Label used for logging.
pagination_label: Either offset or page, used for logging. pagination_label: Either offset or page, used for logging.
retry_attempt: Retry attempt number when handling retry results.
Returns: Returns:
Page numbers whose fetch raised an expected error. Page numbers whose fetch raised an expected error.
@@ -446,7 +594,6 @@ def handle_page_results(
"category": category_identifier, "category": category_identifier,
"label": pagination_label, "label": pagination_label,
"page": page_number, "page": page_number,
"retry": retry_attempt,
}, },
exc_info=result, exc_info=result,
) )
@@ -458,66 +605,58 @@ def handle_page_results(
"category": category_identifier, "category": category_identifier,
"label": pagination_label, "label": pagination_label,
"page": page_number, "page": page_number,
"retry": retry_attempt,
}, },
exc_info=result, exc_info=result,
) )
raise result raise result
elif retry_attempt:
LOGGER.info(
"retry succeeded",
extra={
"category": category_identifier,
"label": pagination_label,
"page": page_number,
"retry": retry_attempt,
},
)
return failed_pages return failed_pages
async def retry_failed_pages( def first_error(group_exc: BaseExceptionGroup) -> BaseException:
failed_pages: list[int], """Return a leaf exception of a possibly nested group, preferring an
fetch_func: Callable[[int], Awaitable[None]], unexpected one so a real bug is never masked by a routine network error."""
category_identifier: str, _, unexpected = group_exc.split(EXPECTED_TASK_ERRORS)
pagination_label: str, if unexpected is not None:
max_retries: int = 3, group_exc = unexpected
) -> list[int]:
"""Retry failed pages with exponential backoff until exhausted or clear.
Sleeps 2**attempt seconds before each round and re-dispatches only the error = group_exc.exceptions[0]
pages that failed the previous round. while isinstance(error, BaseExceptionGroup):
error = error.exceptions[0]
Args: return error
failed_pages: Page numbers to retry.
fetch_func: Awaitable invoked per page number.
category_identifier: Label used for logging.
pagination_label: Either offset or page, used for logging.
max_retries: Maximum retry rounds.
Returns:
Page numbers still failing after all retries. @asynccontextmanager
async def fan_out_group():
"""A TaskGroup for page-one fan-out whose failure surfaces unwrapped.
Use it where one page's processing must be bound to the pagination that
page spawns: create the processing task on the yielded group, then await
execute_pagination in the body. The group cancels whichever sibling is
still running when the other fails, so a dead category stops doing work
nobody will use, and a cancelled cycle takes both down together.
The unwrapping is the reason this is shared rather than inlined. A bare
TaskGroup always raises an ExceptionGroup, and run_monitor_cycle classifies
task results with isinstance against EXPECTED_TASK_ERRORS -- an
ExceptionGroup matches neither branch as intended, so a routine page
failure would be reclassified as an unexpected one and kill the monitor.
first_error unwraps to a leaf, preferring an unexpected exception so a real
bug is never masked by a network error.
Note the caller's own body is inside the group, so a plain exception raised
there (not from a child) propagates unchanged rather than being wrapped,
and an early `return` still awaits the child tasks.
Example:
async with fan_out_group() as group:
group.create_task(process_page(data, ...))
await execute_pagination(...)
""" """
retry_attempt = 1 try:
while failed_pages and retry_attempt <= max_retries: async with asyncio.TaskGroup() as group:
await asyncio.sleep(2**retry_attempt) yield group
LOGGER.info( except BaseExceptionGroup as group_exc:
"retrying failed pages", raise first_error(group_exc) from None
extra={
"category": category_identifier,
"label": pagination_label,
"count": len(failed_pages),
"attempt": retry_attempt,
"max_retries": max_retries,
},
)
tasks = [fetch_func(page_number) for page_number in failed_pages]
results = await asyncio.gather(*tasks, return_exceptions=True)
failed_pages = handle_page_results(
failed_pages, results, category_identifier, pagination_label, retry_attempt
)
retry_attempt += 1
return failed_pages
+113 -12
View File
@@ -6,22 +6,72 @@ request_with_retries, adding per-request proxy rotation and old-style JSON/text
return values so monitor call sites change as little as possible. return values so monitor call sites change as little as possible.
""" """
import asyncio
import logging
import random import random
from aioproxies import AioProxies from aioproxies import AioProxies
from aioweb import Response from aioweb import Response
from aioweb_tls import CurlCffi, TLSSession from aioweb_tls import CurlCffi, TLSSession
from fake_useragent import UserAgent from curl_cffi.requests.impersonate import BrowserTypeLiteral
from monitor_utils.constants import (
CLIENT_IDENTIFIERS,
MAX_CURL_HANDLES,
RETRY_LIMIT,
SESSION_CLOSE_TIMEOUT_SECONDS,
TIMEOUT_SECONDS,
)
from monitor_utils.constants import CLIENT_IDENTIFIERS, RETRY_LIMIT, TIMEOUT_SECONDS LOGGER = logging.getLogger(__name__)
UA = UserAgent()
class BoundedCloseSession(TLSSession):
"""A TLSSession whose close cannot outlive SESSION_CLOSE_TIMEOUT_SECONDS.
curl_cffi's close awaits the multi handle with no timeout of its own, so a
wedged transfer parks whatever is closing the session. A caller-side budget
cannot cover this reliably: asyncio.timeout cancels its task once, so a
cycle budget that has already fired leaves the close running as a fresh,
uncancelled await, and the process parks in the `async with` exit that was
meant to reclaim it.
Bounding it here keeps that guarantee attached to the session itself, so
every `async with get_default_session(...)` gets it without the call site
having to arrange anything. Abandoning a session leaks its handle until the
process restarts, which is strictly better than a wedged monitor.
Known inconsistency, deliberate: an abandoned session reports itself closed
while its curl handle is still open. TLSSession.close sets _closed before
awaiting the backend, so _is_closed() returns True on this path, and since
aioweb's __del__ only warns when _is_closed() is False, its leak warning is
suppressed for exactly the sessions that leaked. The "session close exceeded
budget" error logged below is therefore the only signal that it happened —
alert on it rather than on the library's warning.
Correcting the flag would mean overriding _is_closed too, and aioweb_tls is
a third-party library: this class deliberately overrides one documented
lifecycle method and nothing private, so an upstream release has one narrow
way to break it. A stale flag on a session discarded the same instant is the
cheaper side of that trade.
"""
async def close(self) -> None:
try:
async with asyncio.timeout(SESSION_CLOSE_TIMEOUT_SECONDS):
await super().close()
except TimeoutError:
LOGGER.error(
"session close exceeded budget, abandoning session",
extra={"timeout": SESSION_CLOSE_TIMEOUT_SECONDS},
)
def get_default_session( def get_default_session(
proxies: AioProxies, proxies: AioProxies,
headers: dict | None = None, headers: dict | None = None,
timeout: int = TIMEOUT_SECONDS, timeout: int = TIMEOUT_SECONDS,
max_clients: int = MAX_CURL_HANDLES,
impersonate: BrowserTypeLiteral | None = None,
) -> TLSSession: ) -> TLSSession:
"""Return a TLSSession impersonating a random client fingerprint. """Return a TLSSession impersonating a random client fingerprint.
@@ -43,17 +93,29 @@ def get_default_session(
timeout: Total request timeout in seconds, defaulting to TIMEOUT_SECONDS. timeout: Total request timeout in seconds, defaulting to TIMEOUT_SECONDS.
aioweb's own default is only 10s, so this restores the wider budget aioweb's own default is only 10s, so this restores the wider budget
the old sessions used and slow retailers depend on. the old sessions used and slow retailers depend on.
max_clients: Size of the curl handle pool, defaulting to
MAX_CURL_HANDLES. This is a hard concurrency ceiling: a request
finding the pool empty waits in pop_curl's untimed pool.get(), and
`timeout` does not cover that wait, only the transfer after
checkout. Keep it at or above the monitor's real fan-out (the sum
of its pagination semaphores plus any unsemaphored per-cycle
requests) so the wait is never reachable.
impersonate: A curl_cffi profile to pin instead of drawing one from
CLIENT_IDENTIFIERS, for a retailer whose bot protection only
tolerates some profiles from a given IP (B&H's Cloudflare refuses
every chrome profile on a flagged IP but still accepts safari).
Returns: Returns:
A TLSSession over a curl_cffi backend with a random impersonation, with A BoundedCloseSession (a TLSSession whose close is bounded) over a
the proxy pool attached as proxy_pool. curl_cffi backend with a random impersonation, with the proxy pool
attached as proxy_pool.
""" """
impersonate = impersonate or random.choice(CLIENT_IDENTIFIERS)
headers = headers or {} session = BoundedCloseSession(
headers.setdefault("User-Agent", UA.chrome) backend=CurlCffi(impersonate=impersonate),
impersonate = random.choice(CLIENT_IDENTIFIERS) headers=headers,
session = TLSSession( timeout=timeout,
backend=CurlCffi(impersonate=impersonate), headers=headers, timeout=timeout max_clients=max_clients,
) )
session.proxy_pool = proxies session.proxy_pool = proxies
return session return session
@@ -114,5 +176,44 @@ async def send_request(
attempts=RETRY_LIMIT, attempts=RETRY_LIMIT,
) )
response.raise_for_status() response.raise_for_status()
return response return response
def get_response_json(
response: Response,
*,
logger: logging.Logger = LOGGER,
expected: type | tuple[type, ...] = dict,
**context,
) -> dict | list | None:
"""Parse a response body as JSON, or log it and return None.
aioweb's response.json() yields None for a non-JSON body, so a single
isinstance check covers both the empty/invalid case and a body of the
wrong shape. Callers stop on None the same way they did with the inline
check this replaces.
Args:
response: A 2xx aioweb Response from send_request.
logger: Logger the warning is emitted on; pass the monitor's own so
the record stays attributed to that monitor.
expected: Type(s) the parsed body must be an instance of.
**context: Extra fields for the warning record (category, page, ...).
Returns:
The parsed body when it matches `expected`, else None.
"""
data = response.json()
if isinstance(data, expected):
return data
logger.warning(
"unexpected response",
extra={
**context,
"body_type": type(data).__name__,
"body": str(data)[:200],
},
)
return None
+85
View File
@@ -0,0 +1,85 @@
"""unit test: calculate_priority ranks MSRP-less products instead of burying them
run from the repo root:
uv run --with pytest pytest tests/test_alert_priority.py
pure and offline — passes plain dicts shaped like product documents; touches no
Mongo. The claim query sorts priority descending, so a priority of 0 means an
alert is only drained once everything else is.
"""
from monitor_utils.database import (
NEW_LISTING_PRIORITY,
NO_MSRP_PRIORITY,
calculate_priority,
)
def document(current, original, **overrides) -> dict:
doc = {"_id": "p1", "currentPrice": current, "originalPrice": original}
doc.update(overrides)
return doc
def test_no_msrp_takes_its_own_rank():
assert calculate_priority(document(50.0, 50.0, hasMsrp=False)) == NO_MSRP_PRIORITY
def test_no_msrp_rank_sits_directly_under_the_seventy_band():
"""It must outrank every sub-70% product and lose to every 70%+ one."""
assert calculate_priority(document(25.0, 100.0)) == 7 # 75% off
assert NO_MSRP_PRIORITY < 7
assert NO_MSRP_PRIORITY > calculate_priority(document(45.0, 100.0)) # 55% off
def test_msrp_products_are_unaffected():
assert calculate_priority(document(25.0, 100.0, hasMsrp=True)) == 7
assert calculate_priority(document(50.0, 100.0, hasMsrp=True)) == 5
def test_documents_without_the_field_keep_the_percentage_path():
"""Documents written before hasMsrp existed must not all jump the queue."""
assert calculate_priority(document(50.0, 100.0)) == 5
assert calculate_priority(document(100.0, 100.0)) == 0
def test_broken_prices_still_outrank_nothing():
"""A missing or zero original price is a data defect, not a missing MSRP."""
assert calculate_priority(document(50.0, None, hasMsrp=False)) == 0
assert calculate_priority(document(50.0, 0, hasMsrp=False)) == 0
assert calculate_priority(document(None, 100.0, hasMsrp=False)) == 0
def test_non_finite_prices_resolve_to_zero_instead_of_raising():
"""float("Infinity") from a retailer field once crashed the whole monitor."""
inf, nan = float("inf"), float("nan")
assert calculate_priority(document(50.0, inf)) == 0
assert calculate_priority(document(inf, 100.0)) == 0
assert calculate_priority(document(-inf, 100.0)) == 0
assert calculate_priority(document(nan, 100.0)) == 0
assert calculate_priority(document(50.0, nan)) == 0
assert calculate_priority(document(50.0, inf, newListing=True)) == 0
def test_a_discount_too_large_to_represent_resolves_to_zero():
"""Finite prices can still overflow the ratio (a subnormal original)."""
assert calculate_priority(document(-1e308, 5e-324)) == 0
assert calculate_priority(document(1e308, 5e-324)) == 0
def test_new_listing_is_floored_not_buried():
"""A low-discount new listing sends regardless, so it must not sort last."""
assert (
calculate_priority(document(95.0, 100.0, newListing=True))
== NEW_LISTING_PRIORITY
)
def test_new_listing_keeps_a_higher_percentage_rank():
"""The floor is a floor: a 75%-off new listing still ranks as 75% off."""
assert calculate_priority(document(25.0, 100.0, newListing=True)) == 7
def test_repeat_alerts_keep_the_percentage_path():
assert calculate_priority(document(95.0, 100.0, newListing=False)) == 0
assert calculate_priority(document(95.0, 100.0)) == 0
+243
View File
@@ -0,0 +1,243 @@
"""unit test: build_product_details thresholding, promo read, and combined_discount
run from the repo root:
uv run pytest tests/test_build_product_details.py
pure and offline — no Mongo. Builds a Product from a raw doc dict and checks the
discount threshold (with percentage codes compounding onto the sale price), the
promo fields read through via .get, and the combined_discount helper.
"""
from datetime import datetime, timezone
from monitor_utils.runner import build_product_details, combined_discount
def _doc(**overrides) -> dict:
"""A well-formed product doc; override prices/promo per test."""
doc = {
"_id": "p1_tools",
"title": "Widget",
"url": "http://x/p1",
"currentPrice": 80.0,
"originalPrice": 100.0,
"imageUrl": "http://img/1",
"customizationIndex": None,
"memberNonMemberComparison": None,
"overstockText": None,
"discoveredDate": datetime(2026, 7, 26, tzinfo=timezone.utc),
"startDate": None,
"endDate": None,
"priceBeforeDrop": None,
"eduPrice": None,
"pastPrices": [
{"price": 80.0, "date": datetime(2026, 7, 26, tzinfo=timezone.utc)}
],
"available": True,
"condition": None,
"appExclusive": None,
"memberPick": None,
"memberDeal": None,
}
doc.update(overrides)
return doc
# ------------------------------------------------------------ combined_discount
def test_combined_discount_no_promo_is_base():
assert combined_discount(24, None) == 24
assert combined_discount(24, 0) == 24
def test_combined_discount_compounds_not_adds():
# 20% off then extra 30% code -> 44% off list (customer pays $56), not 50%
assert combined_discount(20, 30) == 44
def test_combined_discount_rounds_to_nearest():
# 24% sale + 22% code: 1 - .76*.78 = .4072 -> 41 (rounded, not truncated 40)
assert combined_discount(24, 22) == 41
# --------------------------------------------------------- build_product_details
def test_promo_fields_read_through():
doc = _doc(
promoCode="BIRTHDAY26",
promoPercentage=22,
promoDollarOff=None,
promoMinSpend=None,
)
product = build_product_details(doc, min_percentage=0)
assert product.metadata.promo_code == "BIRTHDAY26"
assert product.metadata.promo_percentage == 22
# base percentage is the raw price delta, unchanged by the code
assert product.metadata.percentage == 20
def test_asin_reads_through():
"""asin round-trips: convert_product_to_mongo writes it, so the consumer
must read it back for the SellerAmp lookup"""
product = build_product_details(_doc(asin="B0CHX3QBCH"), min_percentage=0)
assert product.metadata.asin == "B0CHX3QBCH"
def test_missing_asin_defaults_to_none():
assert build_product_details(_doc(), min_percentage=0).metadata.asin is None
def test_missing_promo_keys_default_to_none():
"""a doc from a lagging monitor lacks promo keys -> None, not KeyError"""
product = build_product_details(_doc(), min_percentage=0)
assert product.metadata.promo_code is None
assert product.metadata.promo_percentage is None
def test_percentage_code_lifts_item_over_threshold():
# 30% base fails min 40, but +22% code -> 45% combined clears it
doc = _doc(currentPrice=70.0, originalPrice=100.0, promoPercentage=22)
assert build_product_details(doc, min_percentage=40) is not None
def test_below_threshold_without_code_is_skipped():
doc = _doc(currentPrice=70.0, originalPrice=100.0) # 30%, no code
assert build_product_details(doc, min_percentage=40) is None
def test_dollar_code_does_not_affect_threshold():
# a dollar code has no promoPercentage, so 30% base stays below min 40
doc = _doc(
currentPrice=70.0,
originalPrice=100.0,
promoCode="STORE22",
promoDollarOff=22,
promoMinSpend=100,
)
assert build_product_details(doc, min_percentage=40) is None
# ----------------------------------------------------------------- pastPrices
def test_past_prices_read_through():
"""Price history passes through unchanged."""
history = [
{"price": 95.0, "date": datetime(2026, 7, 20, tzinfo=timezone.utc)},
{"price": 90.0, "date": datetime(2026, 7, 24, tzinfo=timezone.utc)},
]
product = build_product_details(_doc(pastPrices=history), min_percentage=0)
assert product.pricing.past_prices == history
# ----------------------------------------------------------- bypass_thresholds
def test_bypass_builds_a_below_threshold_product():
"""A new listing sends at any discount when the caller says so."""
doc = _doc(currentPrice=95.0, originalPrice=100.0) # 5%, way under 40
product = build_product_details(doc, min_percentage=40, bypass_thresholds=True)
assert product is not None
assert product.metadata.percentage == 5
def test_bypass_defaults_off():
doc = _doc(currentPrice=95.0, originalPrice=100.0)
assert build_product_details(doc, min_percentage=40) is None
def test_bypass_does_not_rescue_broken_prices():
"""The missing-price skip is a data guard, not a threshold."""
doc = _doc(currentPrice=95.0, originalPrice=None)
assert build_product_details(doc, min_percentage=40, bypass_thresholds=True) is None
# --------------------------------------------------------------- newListing
def test_new_listing_flag_reads_through():
product = build_product_details(_doc(newListing=True), min_percentage=0)
assert product.metadata.new_listing is True
def test_legacy_docs_without_the_flag_build_as_none():
"""Documents written before newListing existed must not KeyError."""
product = build_product_details(_doc(), min_percentage=0)
assert product.metadata.new_listing is None
# ------------------------------------------------------------ msrp-less guard
def test_msrpless_doc_with_missing_original_price_skips_not_crashes():
"""Regression: the guard lived inside the has_msrp branch, so this doc
shape raised ValidationError (not an expected error) and killed the
consumer."""
doc = _doc(hasMsrp=False, originalPrice=None)
assert build_product_details(doc, min_percentage=40) is None
def test_msrpless_doc_with_zero_original_price_skips():
doc = _doc(hasMsrp=False, originalPrice=0)
assert build_product_details(doc, min_percentage=40) is None
def test_bypass_does_not_rescue_msrpless_broken_prices():
doc = _doc(hasMsrp=False, originalPrice=None)
assert build_product_details(doc, min_percentage=40, bypass_thresholds=True) is None
def test_msrpless_doc_with_prices_builds_unthresholded():
"""A priced MSRP-less doc still skips the threshold gate entirely."""
doc = _doc(hasMsrp=False, currentPrice=95.0, originalPrice=100.0) # 5% < 40
product = build_product_details(doc, min_percentage=40)
assert product is not None
assert product.pricing.has_msrp is False
# ------------------------------------------------------------ non-finite prices
def test_infinite_price_skips_instead_of_raising():
"""Regression (2026-10-09 06:25Z): float("Infinity") from a retailer field
raised OverflowError here and stopped the consumer's API deal loop."""
inf = float("inf")
assert build_product_details(_doc(originalPrice=inf), min_percentage=40) is None
assert build_product_details(_doc(currentPrice=inf), min_percentage=40) is None
assert build_product_details(_doc(currentPrice=-inf), min_percentage=40) is None
def test_nan_price_skips_instead_of_raising():
nan = float("nan")
assert build_product_details(_doc(currentPrice=nan), min_percentage=40) is None
assert build_product_details(_doc(originalPrice=nan), min_percentage=40) is None
def test_msrpless_infinite_price_skips_rather_than_reaching_the_payload():
"""Without the skip the MSRP-less path would carry inf into the Product."""
doc = _doc(hasMsrp=False, currentPrice=float("inf"))
assert build_product_details(doc, min_percentage=40) is None
def test_bypass_does_not_rescue_a_non_finite_price():
doc = _doc(originalPrice=float("inf"))
assert build_product_details(doc, min_percentage=40, bypass_thresholds=True) is None
def test_unrepresentable_discount_skips_instead_of_raising():
"""Finite prices can still overflow the ratio (a subnormal original)."""
doc = _doc(currentPrice=-1e308, originalPrice=5e-324)
assert build_product_details(doc, min_percentage=40) is None
+57 -2
View File
@@ -11,15 +11,16 @@ Product), which is why convert_product_to_mongo no longer coerces them itself.
from datetime import datetime from datetime import datetime
from monitor_utils.database import convert_product_to_mongo from monitor_utils.database import convert_product_to_mongo
from monitor_utils.models import PricingInfo, Product from monitor_utils.models import PricingInfo, Product, ProductMetadata
def _product(**pricing) -> Product: def _product(metadata: ProductMetadata | None = None, **pricing) -> Product:
return Product( return Product(
product_id="p1", product_id="p1",
title="Widget", title="Widget",
url="http://x/p1", url="http://x/p1",
pricing=PricingInfo(**pricing), pricing=PricingInfo(**pricing),
metadata=metadata or ProductMetadata(),
) )
@@ -46,3 +47,57 @@ def test_core_fields_map_through():
doc = convert_product_to_mongo(_product(original_price=200.0, current_price=150.0)) doc = convert_product_to_mongo(_product(original_price=200.0, current_price=150.0))
assert doc["title"] == "Widget" assert doc["title"] == "Widget"
assert doc["url"] == "http://x/p1" assert doc["url"] == "http://x/p1"
def test_promo_and_affiliate_fields_serialize():
"""promo specifics + affiliate url reach the document under camelCase keys"""
doc = convert_product_to_mongo(
_product(
metadata=ProductMetadata(
affiliate_url="http://aff/p1",
promo_code="BIRTHDAY26",
promo_percentage=22,
promo_dollar_off=None,
promo_min_spend=None,
),
original_price=200.0,
current_price=150.0,
)
)
assert doc["affiliateUrl"] == "http://aff/p1"
assert doc["promoCode"] == "BIRTHDAY26"
assert doc["promoPercentage"] == 22
assert doc["promoDollarOff"] is None
assert doc["promoMinSpend"] is None
def test_dollar_promo_fields_serialize():
"""a spend-threshold code stores amount + min spend, not a percentage"""
doc = convert_product_to_mongo(
_product(
metadata=ProductMetadata(
promo_code="STORE22",
promo_dollar_off=22,
promo_min_spend=100,
),
original_price=200.0,
current_price=150.0,
)
)
assert doc["promoCode"] == "STORE22"
assert doc["promoDollarOff"] == 22
assert doc["promoMinSpend"] == 100
assert doc["promoPercentage"] is None
def test_promo_fields_default_to_none():
"""a product with no promo still carries the keys as None (uniform doc shape)"""
doc = convert_product_to_mongo(_product(original_price=200.0, current_price=150.0))
for key in ("promoCode", "promoPercentage", "promoDollarOff", "promoMinSpend"):
assert doc[key] is None
# percentage and channelId stay OUT of the doc on purpose
assert "percentage" not in doc
assert "channelId" not in doc
+126
View File
@@ -0,0 +1,126 @@
"""unit test: fan_out_group binds sibling tasks and surfaces errors unwrapped
run from the repo root:
uv run pytest tests/test_fan_out_group.py
offline — no network. These lock the contract monitors depend on: a bare
TaskGroup always raises an ExceptionGroup, which run_monitor_cycle would
misclassify (it matches neither the EXPECTED_TASK_ERRORS branch nor the
unexpected-Exception branch as intended), so the unwrapping is load-bearing and
not cosmetic.
"""
import asyncio
import aiohttp
import pytest
from monitor_utils.runner import fan_out_group
async def _boom(error: BaseException):
raise error
def test_child_error_surfaces_unwrapped():
"""A failing child arrives as itself, not wrapped in an ExceptionGroup."""
async def scenario():
async with fan_out_group() as group:
group.create_task(_boom(RuntimeError("child")))
await asyncio.sleep(0)
with pytest.raises(RuntimeError) as exc_info:
asyncio.run(scenario())
assert not isinstance(exc_info.value, BaseExceptionGroup)
assert str(exc_info.value) == "child"
def test_body_error_surfaces_unwrapped():
"""An error raised by the caller's own body propagates unchanged."""
async def ok():
await asyncio.sleep(0)
async def scenario():
async with fan_out_group() as group:
group.create_task(ok())
raise ValueError("body")
with pytest.raises(ValueError) as exc_info:
asyncio.run(scenario())
assert not isinstance(exc_info.value, BaseExceptionGroup)
def test_unexpected_error_is_preferred_over_an_expected_one():
"""first_error must not let a routine network error mask a real bug."""
async def scenario():
async with fan_out_group() as group:
group.create_task(_boom(aiohttp.ClientError("routine")))
await asyncio.sleep(0)
raise ValueError("real bug")
with pytest.raises(ValueError):
asyncio.run(scenario())
def test_early_return_still_awaits_children():
"""A `return` inside the body must not abandon an in-flight child task."""
finished = []
async def child():
await asyncio.sleep(0.01)
finished.append("done")
async def scenario():
async with fan_out_group() as group:
group.create_task(child())
return "returned"
assert asyncio.run(scenario()) == "returned"
assert finished == ["done"]
def test_parked_sibling_is_cancelled_when_the_body_fails():
"""The binding is the point: a child that never finishes must not hang.
Without the group, a create_task + `finally: await` shape would park here
forever. The deadline is the assertion — a hang fails this test.
"""
async def parked():
await asyncio.Event().wait()
async def scenario():
async with asyncio.timeout(2):
async with fan_out_group() as group:
group.create_task(parked())
await asyncio.sleep(0)
raise RuntimeError("body")
with pytest.raises(RuntimeError):
asyncio.run(scenario())
def test_children_run_concurrently_with_the_body():
"""Both siblings make progress; the group is not a sequencing device."""
order = []
async def child():
order.append("child-start")
await asyncio.sleep(0.01)
order.append("child-end")
async def scenario():
async with fan_out_group() as group:
group.create_task(child())
order.append("body")
await asyncio.sleep(0.02)
asyncio.run(scenario())
assert order == ["body", "child-start", "child-end"]
+43
View File
@@ -0,0 +1,43 @@
"""unit test: fetch_all_pages result accounting for any iterable input
run from the repo root:
uv run pytest tests/test_fetch_all_pages.py
pure and offline — no HTTP. Regression: a generator page_range was exhausted
building the tasks, so the result pairing dropped every failure and swallowed
unexpected exceptions.
"""
import asyncio
import pytest
from aioweb import AiowebError
from monitor_utils.runner import fetch_all_pages
async def _failing_fetch(page: int):
raise AiowebError(f"page {page} failed")
async def _crashing_fetch(page: int):
raise ValueError(f"page {page} crashed")
def test_generator_input_still_reports_failed_pages():
failed = asyncio.run(
fetch_all_pages((p for p in [2, 3]), _failing_fetch, "cat", "page")
)
assert failed == [2, 3]
def test_generator_input_still_raises_unexpected_errors():
with pytest.raises(ValueError):
asyncio.run(fetch_all_pages((p for p in [2]), _crashing_fetch, "cat", "page"))
def test_range_input_reports_failed_pages():
"""The re-iterable path the in-repo caller uses keeps working."""
failed = asyncio.run(fetch_all_pages(range(2, 4), _failing_fetch, "cat", "page"))
assert failed == [2, 3]
+80
View File
@@ -0,0 +1,80 @@
"""unit tests: sessions.get_response_json parses a body or logs and returns None
run from the repo root:
uv run pytest tests/test_response_json.py
offline — the aioweb Response is faked at the leaf; nothing is sent.
"""
import logging
import pytest
from monitor_utils.sessions import get_response_json
class FakeResponse:
def __init__(self, body):
self.body = body
def json(self):
return self.body
def warnings(caplog):
return [r for r in caplog.records if r.levelno == logging.WARNING]
def test_dict_body_is_returned_without_logging(caplog):
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
assert get_response_json(FakeResponse({"a": 1})) == {"a": 1}
assert warnings(caplog) == []
def test_none_body_logs_and_returns_none(caplog):
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
assert get_response_json(FakeResponse(None), category="c1", page=2) is None
(record,) = warnings(caplog)
assert record.message == "unexpected response"
assert record.body_type == "NoneType"
assert record.body == "None"
assert (record.category, record.page) == ("c1", 2)
@pytest.mark.parametrize("body", [[1, 2], "text", 42, b"bytes"])
def test_wrong_shape_logs_type_and_truncated_body(body, caplog):
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
assert get_response_json(FakeResponse(body)) is None
(record,) = warnings(caplog)
assert record.body_type == type(body).__name__
assert record.body == str(body)[:200]
def test_body_preview_is_capped_at_200_chars(caplog):
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
get_response_json(FakeResponse("x" * 1000))
(record,) = warnings(caplog)
assert len(record.body) == 200
def test_expected_accepts_other_types(caplog):
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
assert get_response_json(FakeResponse([1]), expected=list) == [1]
assert get_response_json(FakeResponse([1]), expected=(dict, list)) == [1]
assert get_response_json(FakeResponse({}), expected=list) is None
assert len(warnings(caplog)) == 1
def test_warning_goes_to_the_passed_logger(caplog):
with caplog.at_level(logging.WARNING):
get_response_json(
FakeResponse(None), logger=logging.getLogger("woot.woot_monitor")
)
(record,) = warnings(caplog)
assert record.name == "woot.woot_monitor"
def test_empty_dict_counts_as_a_dict(caplog):
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
assert get_response_json(FakeResponse({})) == {}
assert warnings(caplog) == []
+146
View File
@@ -0,0 +1,146 @@
"""unit test: add_products_to_database stamps newListing and copies it onto alerts
run from the repo root:
uv run pytest tests/test_new_listing_stamp.py
offline — drives add_products_to_database against fake collections that record
the UpdateOne ops instead of touching Mongo. The recorded ops are inspected via
UpdateOne's private _doc/_filter, the same fields its own __eq__ compares.
The stamp is the database layer's job: only it knows whether the doc already
exists, so monitors never set the flag. The alert carries its own copy because
the product doc is rewritten every cycle (its flag flips False one cycle after
discovery) while a discovery alert may outlive that in a backed-up queue.
"""
import asyncio
from monitor_utils.database import add_products_to_database, build_alert
from monitor_utils.models import PricingInfo, Product
from datetime import datetime, timezone
NOW = datetime(2026, 8, 10, tzinfo=timezone.utc)
class FakeCursor:
def __init__(self, docs):
self._docs = list(docs)
def __aiter__(self):
return self
async def __anext__(self):
if not self._docs:
raise StopAsyncIteration
return self._docs.pop(0)
class FakeCollection:
"""Records bulk_write ops; doubles as its own .database for alerts lookup."""
def __init__(self, existing_docs=(), name="products"):
self.name = name
self._existing = list(existing_docs)
self.bulk_writes = []
self.database = self
self.alerts = None
def __getitem__(self, name):
if self.alerts is None:
self.alerts = FakeCollection(name=name)
return self.alerts
def find(self, _filter, _projection):
return FakeCursor(self._existing)
async def bulk_write(self, ops, ordered=True):
self.bulk_writes.append(list(ops))
def _product(product_id="p1", current=95.0, original=100.0) -> Product:
return Product(
product_id=product_id,
title="Widget",
url="http://x/p1",
pricing=PricingInfo(current_price=current, original_price=original),
)
def run(collection, products):
asyncio.run(
add_products_to_database(products, collection, "tools", "bestbuy")
)
def product_update(collection):
return collection.bulk_writes[0][0]._doc
def alert_doc(collection):
return collection.alerts.bulk_writes[0][0]._doc["$setOnInsert"]
def test_insert_stamps_new_listing_true_on_doc_and_alert():
collection = FakeCollection()
run(collection, [_product()])
assert product_update(collection)["$set"]["newListing"] is True
assert alert_doc(collection)["newListing"] is True
def test_existing_doc_with_new_price_stamps_false():
existing = {"_id": "p1", "currentPrice": 100.0, "pastPrices": []}
collection = FakeCollection([existing])
run(collection, [_product(current=95.0)])
assert product_update(collection)["$set"]["newListing"] is False
# a never-seen price still alerts, but not as a new listing
assert alert_doc(collection)["newListing"] is False
def test_drop_alert_carries_price_before_drop():
"""the doc's priceBeforeDrop is cleared by the next no-drop cycle, so the
alert keeps its own copy from emit time — a drop claimed late still knows
what it dropped from"""
existing = {"_id": "p1", "currentPrice": 100.0, "pastPrices": []}
collection = FakeCollection([existing])
run(collection, [_product(current=95.0)])
assert alert_doc(collection)["priceBeforeDrop"] == 100.0
def test_non_drop_alert_carries_none():
"""a never-seen higher price alerts without a drop; discoveries have no
prior price at all"""
existing = {"_id": "p1", "currentPrice": 100.0, "pastPrices": []}
collection = FakeCollection([existing])
run(collection, [_product(current=120.0)])
assert alert_doc(collection)["priceBeforeDrop"] is None
discovery = FakeCollection()
run(discovery, [_product()])
assert alert_doc(discovery)["priceBeforeDrop"] is None
def test_existing_doc_with_known_price_emits_no_alert():
existing = {
"_id": "p1",
"currentPrice": 95.0,
"pastPrices": [{"price": 95.0, "date": NOW}],
}
collection = FakeCollection([existing])
run(collection, [_product(current=95.0)])
assert product_update(collection)["$set"]["newListing"] is False
assert collection.alerts is None
def test_build_alert_defaults_missing_fields():
"""Direct callers passing an unstamped document must not KeyError."""
alert = build_alert("bestbuy", {"_id": "p1"}, NOW)
assert alert["newListing"] is False
assert alert["priceBeforeDrop"] is None
+23
View File
@@ -82,3 +82,26 @@ def test_single_call_respects_its_semaphore():
asyncio.run(run()) asyncio.run(run())
assert state["peak"] <= limit, f"peak {state['peak']} over limit {limit}" assert state["peak"] <= limit, f"peak {state['peak']} over limit {limit}"
def test_omitted_semaphore_runs_unbounded():
"""Omitting the semaphore lets all pages fan out concurrently.
With no semaphore the only bound is the page count; pages 2..10 (nine pages)
should all be in flight at once, so peak concurrency reaches nine.
"""
state = {"live": 0, "peak": 0}
func = _make_tracking_func(state)
async def run():
await execute_pagination(
"cat",
total_products=300,
products_per_page=30,
func=func,
session=None,
)
asyncio.run(run())
assert state["peak"] == 9, f"expected all 9 pages concurrent, got {state['peak']}"
+147
View File
@@ -0,0 +1,147 @@
"""unit test: run_bounded_cycle session lifetime, budget, and pass-through
run from the repo root:
uv run pytest tests/test_run_bounded_cycle.py
offline — get_default_session is faked at the runner's namespace so no proxies
or network are touched; run_monitor_cycle underneath runs real. Gap: the real
session wiring (timeout/max_clients reaching curl) is covered by the monitors'
own pool-size tests, not here.
"""
import asyncio
import logging
import aiohttp
import pytest
from monitor_utils import runner
from monitor_utils.models import CycleResult
from monitor_utils.runner import run_bounded_cycle
class FakeSession:
def __init__(self):
self.closed = False
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
self.closed = True
def stub_session(monkeypatch) -> dict:
"""Fake get_default_session, recording each call's kwargs and session."""
calls = []
def fake(proxies, headers=None, timeout=None, max_clients=None):
session = FakeSession()
calls.append(
{
"headers": headers,
"timeout": timeout,
"max_clients": max_clients,
"session": session,
}
)
return session
monkeypatch.setattr(runner, "get_default_session", fake)
return calls
def test_returns_cycle_result_and_closes_session(monkeypatch):
calls = stub_session(monkeypatch)
async def task(category, session, collection, **kwargs):
pass
result = asyncio.run(run_bounded_cycle(["a", "b"], task, None, None))
assert result == CycleResult(successes=2, failures=0)
assert calls[0]["session"].closed
def test_session_params_reach_the_factory(monkeypatch):
calls = stub_session(monkeypatch)
async def task(category, session, collection, **kwargs):
pass
asyncio.run(
run_bounded_cycle(
["a"],
task,
None,
None,
headers={"referer": "https://x/"},
session_timeout=8,
max_clients=81,
)
)
assert calls[0]["headers"] == {"referer": "https://x/"}
assert calls[0]["timeout"] == 8
assert calls[0]["max_clients"] == 81
def test_expired_budget_returns_none_not_raise(monkeypatch, caplog):
"""A cycle past its budget is abandoned and logged, and the loop lives on."""
calls = stub_session(monkeypatch)
async def parked(category, session, collection, **kwargs):
await asyncio.Event().wait()
with caplog.at_level(logging.ERROR, logger="monitor_utils.runner"):
result = asyncio.run(
run_bounded_cycle(["a"], parked, None, None, cycle_timeout=0.05)
)
assert result is None
assert calls[0]["session"].closed
assert any(
r.message == "cycle exceeded budget, abandoning cycle" for r in caplog.records
)
def test_expected_task_error_counts_as_failure(monkeypatch):
stub_session(monkeypatch)
async def flaky(category, session, collection, **kwargs):
raise aiohttp.ClientError("routine")
result = asyncio.run(run_bounded_cycle(["a"], flaky, None, None))
assert result == CycleResult(successes=0, failures=1)
def test_unexpected_error_propagates_and_closes_session(monkeypatch):
"""fail loud: a real bug must escape the helper, not vanish into a log."""
calls = stub_session(monkeypatch)
async def broken(category, session, collection, **kwargs):
raise RuntimeError("bug")
with pytest.raises(RuntimeError):
asyncio.run(run_bounded_cycle(["a"], broken, None, None))
assert calls[0]["session"].closed
def test_each_call_opens_a_fresh_session(monkeypatch):
calls = stub_session(monkeypatch)
async def task(category, session, collection, **kwargs):
pass
async def two_cycles():
await run_bounded_cycle(["a"], task, None, None)
await run_bounded_cycle(["a"], task, None, None)
asyncio.run(two_cycles())
assert len(calls) == 2
assert calls[0]["session"] is not calls[1]["session"]
+135
View File
@@ -0,0 +1,135 @@
"""unit test: a session's close cannot park the caller indefinitely
run from the repo root:
uv run pytest tests/test_session_close_budget.py
offline — no network. curl_cffi's close awaits the multi handle with no timeout
of its own, so a wedged transfer would park whatever closes the session,
including an `async with` exit. These build a real session and wedge the
backend's close, asserting the bound lives on the session itself so every call
site gets it without arranging anything.
"""
import asyncio
import logging
from aioproxies import AioProxies
from monitor_utils import sessions
from monitor_utils.sessions import BoundedCloseSession, get_default_session
def _proxies(tmp_path) -> AioProxies:
"""A one-entry pool in the host:port:user:pass format monitors ship."""
proxy_file = tmp_path / "proxies.txt"
proxy_file.write_text("proxy.example.io:8080:user:secret\n")
return AioProxies.from_file(path=str(proxy_file))
async def _wedge_close(session) -> asyncio.Event:
"""Replace the backend's close with one that never returns.
Builds the backend session first so TLSSession.close actually reaches the
backend rather than short-circuiting on an unbuilt session.
"""
session.session # force lazy construction
started = asyncio.Event()
async def wedged(inner_session):
started.set()
await asyncio.sleep(3600)
session.backend.close = wedged
return started
async def _really_close(session, original_close):
"""Close for real, so the test does not leak the curl handle it allocated."""
session.backend.close = original_close
await session.close()
def test_close_is_abandoned_when_it_exceeds_the_budget(monkeypatch, caplog, tmp_path):
"""A close that never returns is abandoned and logged, not awaited forever."""
monkeypatch.setattr(sessions, "SESSION_CLOSE_TIMEOUT_SECONDS", 0.05)
proxies = _proxies(tmp_path)
async def run():
session = get_default_session(proxies, timeout=8)
original_close = session.backend.close
started = await _wedge_close(session)
# wait_for is the assertion: an unbounded close never returns here
await asyncio.wait_for(session.close(), timeout=5)
assert started.is_set()
await _really_close(session, original_close)
with caplog.at_level(logging.ERROR, logger="monitor_utils.sessions"):
asyncio.run(run())
assert any(
record.message == "session close exceeded budget, abandoning session"
for record in caplog.records
)
def test_async_with_exit_is_bounded(monkeypatch, caplog, tmp_path):
"""The bound applies through `async with`, which is how monitors close."""
monkeypatch.setattr(sessions, "SESSION_CLOSE_TIMEOUT_SECONDS", 0.05)
proxies = _proxies(tmp_path)
async def run():
session = get_default_session(proxies, timeout=8)
original_close = session.backend.close
started = await _wedge_close(session)
async with session:
pass
assert started.is_set()
await _really_close(session, original_close)
with caplog.at_level(logging.ERROR, logger="monitor_utils.sessions"):
asyncio.run(asyncio.wait_for(run(), timeout=5))
assert any(
record.message == "session close exceeded budget, abandoning session"
for record in caplog.records
)
def test_healthy_close_completes_and_is_not_logged(caplog, tmp_path):
"""The normal path closes fully and reports nothing."""
proxies = _proxies(tmp_path)
async def run():
session = get_default_session(proxies, timeout=8)
session.session # force lazy construction so close has work to do
await session.close()
return session
with caplog.at_level(logging.ERROR, logger="monitor_utils.sessions"):
session = asyncio.run(run())
assert session._is_closed()
assert not caplog.records
def test_get_default_session_returns_a_bounded_session(tmp_path):
"""The factory hands out the bounded type, not a bare TLSSession."""
proxies = _proxies(tmp_path)
async def run():
session = get_default_session(proxies, timeout=8)
try:
return isinstance(session, BoundedCloseSession)
finally:
await session.close()
assert asyncio.run(run())