fix: release cooldown on pre-request failures

This commit is contained in:
ik
2026-09-21 20:47:23 +07:00
parent 59bf85ac23
commit a28621fde3
2 changed files with 78 additions and 14 deletions
+53 -6
View File
@@ -138,7 +138,7 @@ def mark_fetch(source: str, *, state_file: Path, now: float | None = None) -> No
def check_and_reserve(
source: str, *, state_file: Path, now: float | None = None,
) -> None:
) -> float:
"""A02: Atomic check-and-reserve under a single exclusive lock.
Opens state file with exclusive lock, reads state, checks cooldown,
@@ -183,6 +183,36 @@ def check_and_reserve(
sf.flush()
os.fsync(sf.fileno())
temp_file.replace(state_file)
return now
finally:
fcntl.flock(lf, fcntl.LOCK_UN)
def release_reservation(source: str, *, state_file: Path, reserved_at: float) -> bool:
"""Release only the reservation created by this attempt.
A local failure before an HTTP response must not consume the site window.
The timestamp comparison prevents an older process from deleting a newer
reservation made after its own attempt was superseded.
"""
state_file.parent.mkdir(parents=True, exist_ok=True)
lock_file = state_file.with_suffix(".lock")
lock_file.touch(exist_ok=True)
with open(lock_file, "w") as lf:
fcntl.flock(lf, fcntl.LOCK_EX)
try:
state = _read_state(state_file)
current = state.get(source)
if not isinstance(current, (int, float)) or current != reserved_at:
return False
state.pop(source, None)
temp_file = state_file.with_suffix(".tmp")
with open(temp_file, "w") as sf:
sf.write(json.dumps(state, sort_keys=True))
sf.flush()
os.fsync(sf.fileno())
temp_file.replace(state_file)
return True
finally:
fcntl.flock(lf, fcntl.LOCK_UN)
@@ -321,9 +351,18 @@ def main(argv: list[str] | None = None) -> int:
"--reserve-only", action="store_true",
help="Reserve the shared site cooldown and stop before making an HTTP request",
)
parser.add_argument(
"--release-reservation", action="store_true",
help="Release a reservation that failed before reaching the source",
)
parser.add_argument("--reserved-at", type=float, help="Timestamp returned by --reserve-only")
args = parser.parse_args(argv)
if args.html and args.reserve_only:
parser.error("--html cannot be combined with --reserve-only")
if args.release_reservation and args.reserved_at is None:
parser.error("--release-reservation requires --reserved-at")
if args.release_reservation and args.html:
parser.error("--release-reservation cannot be combined with --html")
if args.source in DETAIL_SOURCES and not args.url:
parser.error(f"--url is required for {args.source}")
default_url, parse = SOURCES.get(args.source, (None, DETAIL_SOURCES.get(args.source)))
@@ -333,12 +372,20 @@ def main(argv: list[str] | None = None) -> int:
html = args.html.read_text(encoding="utf-8")
else:
site_key = fetch_site_key(url)
# Single atomic check-and-reserve before network I/O: failed attempts count toward the limit too.
check_and_reserve(site_key, state_file=args.state_file)
if args.reserve_only:
print(json.dumps({"reserved": True, "source": args.source, "site_key": site_key}, ensure_ascii=False))
if args.release_reservation:
released = release_reservation(site_key, state_file=args.state_file, reserved_at=args.reserved_at)
print(json.dumps({"released": released, "source": args.source, "site_key": site_key}, ensure_ascii=False))
return 0
html = fetch_html(url)
reserved_at = check_and_reserve(site_key, state_file=args.state_file)
if args.reserve_only:
print(json.dumps({"reserved": True, "reserved_at": reserved_at, "source": args.source, "site_key": site_key}, ensure_ascii=False))
return 0
try:
html = fetch_html(url)
except urllib.error.URLError as exc:
if not isinstance(exc, urllib.error.HTTPError):
release_reservation(site_key, state_file=args.state_file, reserved_at=reserved_at)
raise
parsed = (
parse(html, source_url=url)
if args.source in DETAIL_SOURCES or args.source == "rf4db-waterbodies"