fix: release cooldown on pre-request failures
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user