python-library-ff14-the-hunt 0.0.4__tar.gz → 0.0.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/.cursor/skills/ff14_the_hunt-package-changelog/SKILL.md +5 -0
  2. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/PKG-INFO +1 -1
  3. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/bear_tracker/client.py +3 -5
  4. python_library_ff14_the_hunt-0.0.6/ff14_the_hunt/common/urlopen_retry.py +75 -0
  5. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/ff14_the_hunt.py +28 -8
  6. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/spawn_map/region_fetch.py +3 -5
  7. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/pyproject.toml +1 -1
  8. python_library_ff14_the_hunt-0.0.6/tests/__init__.py +0 -0
  9. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/tests/test_ff14_the_hunt_service.py +27 -1
  10. python_library_ff14_the_hunt-0.0.6/tests/test_urlopen_retry.py +59 -0
  11. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/.cursor/skills/ff14_the_hunt-package-design/SKILL.md +0 -0
  12. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/.cursor/skills/ff14_the_hunt-package-preload/SKILL.md +0 -0
  13. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/.gitignore +0 -0
  14. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/README.md +0 -0
  15. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/example/__main__.py +0 -0
  16. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/example/spawn_map_io.py +0 -0
  17. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/example.bat +0 -0
  18. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/__init__.py +0 -0
  19. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/bear_tracker/__init__.py +0 -0
  20. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/bear_tracker/enrich.py +0 -0
  21. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/bear_tracker/fate_timer.py +0 -0
  22. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/bear_tracker/resources.py +0 -0
  23. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/bear_tracker/spawn_points.py +0 -0
  24. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/bear_tracker/spawn_window.py +0 -0
  25. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/bear_tracker/timer_theme.py +0 -0
  26. {python_library_ff14_the_hunt-0.0.4/ff14_the_hunt/locale/data → python_library_ff14_the_hunt-0.0.6/ff14_the_hunt/common}/__init__.py +0 -0
  27. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/locale/__init__.py +0 -0
  28. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/locale/cn.py +0 -0
  29. {python_library_ff14_the_hunt-0.0.4/tests → python_library_ff14_the_hunt-0.0.6/ff14_the_hunt/locale/data}/__init__.py +0 -0
  30. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/locale/data/hunts_zh.json +0 -0
  31. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/locale/data/regions_zh.json +0 -0
  32. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/locale/detect.py +0 -0
  33. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/locale/display.py +0 -0
  34. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/locale/en.py +0 -0
  35. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/locale/names.py +0 -0
  36. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/locale/spawn_map_display.py +0 -0
  37. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/locale/tag.py +0 -0
  38. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/models.py +0 -0
  39. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/poll/__init__.py +0 -0
  40. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/poll/loop.py +0 -0
  41. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/poll/monitor_mode.py +0 -0
  42. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/poll/scheduler.py +0 -0
  43. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/poll/sleep_plan.py +0 -0
  44. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/poll/sleep_settings.py +0 -0
  45. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/poll/spawn_dedup.py +0 -0
  46. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/poll/window_remaining.py +0 -0
  47. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/spawn_map/__init__.py +0 -0
  48. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/spawn_map/attach.py +0 -0
  49. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/spawn_map/coordinates.py +0 -0
  50. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/spawn_map/layout.py +0 -0
  51. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/spawn_map/png_meta.py +0 -0
  52. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/spawn_map/points.py +0 -0
  53. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/ff14_the_hunt/spawn_map/region_image.py +0 -0
  54. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/test.bat +0 -0
  55. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/tests/test_fate_timer.py +0 -0
  56. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/tests/test_locale_cn.py +0 -0
  57. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/tests/test_monitor_mode.py +0 -0
  58. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/tests/test_poll_scheduler.py +0 -0
  59. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/tests/test_poll_sleep.py +0 -0
  60. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/tests/test_spawn_dedup.py +0 -0
  61. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/tests/test_spawn_map_data.py +0 -0
  62. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/tests/test_spawn_map_points.py +0 -0
  63. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/tests/test_spawn_window.py +0 -0
  64. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/tests/test_untimed_marks_filter.py +0 -0
  65. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/tools/build_locale_data.py +0 -0
  66. {python_library_ff14_the_hunt-0.0.4 → python_library_ff14_the_hunt-0.0.6}/update.bat +0 -0
@@ -7,6 +7,11 @@ description: ff14_the_hunt 包:要求与决议;最新在上。
7
7
 
8
8
  (规则见 `~/.cursor/skills/agent-project-changelog/SKILL.md`。)
9
9
 
10
+ ## 2026-06-10
11
+
12
+ - **决议**:自动轮询:爬取、回调或循环内任意异常均记录后等待 60 秒继续,仅 ``KeyboardInterrupt`` / ``SystemExit`` 可终止服务。发布 ``0.0.6``。
13
+ - **决议**:Bear Tracker / 区域地图 HTTP 经 ``common/urlopen_retry`` 对瞬时 TLS/网络错误指数退避重试。发布 ``0.0.5``。
14
+
10
15
  ## 2026-06-09
11
16
 
12
17
  - **决议**:``region_map_image_url`` 对区域名做 URL 编码,修复含空格等区域名(如 ``Yak T'el``)拉取狩猎地图时 ``InvalidURL`` 导致 ff14hub 崩溃。发布 ``0.0.4``。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-library-ff14-the-hunt
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Requires-Python: >=3.10
5
5
  Requires-Dist: pydantic>=2.0
6
6
  Provides-Extra: dev
@@ -5,6 +5,8 @@ import urllib.error
5
5
  import urllib.request
6
6
  from typing import Any
7
7
 
8
+ from ff14_the_hunt.common.urlopen_retry import urlopen_read
9
+
8
10
  DEFAULT_BASE_URL = "https://tracker.beartoolkit.com/api"
9
11
 
10
12
 
@@ -97,11 +99,7 @@ class BearTrackerClient:
97
99
  method="POST",
98
100
  )
99
101
  try:
100
- with urllib.request.urlopen(
101
- request,
102
- timeout=self._timeout_seconds,
103
- ) as response:
104
- raw = response.read()
102
+ raw = urlopen_read(request, timeout=self._timeout_seconds)
105
103
  except urllib.error.HTTPError as exc:
106
104
  detail = exc.read().decode("utf-8", errors="replace")
107
105
  raise RuntimeError(
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import ssl
4
+ import time
5
+ import urllib.error
6
+ import urllib.request
7
+
8
+ _RETRYABLE_HTTP_CODES = frozenset({408, 429, 500, 502, 503, 504})
9
+ _DEFAULT_MAX_ATTEMPTS = 4
10
+ _DEFAULT_INITIAL_BACKOFF_SECONDS = 1.0
11
+ _DEFAULT_MAX_BACKOFF_SECONDS = 8.0
12
+
13
+
14
+ def is_retryable_urlopen_error(exc: BaseException) -> bool:
15
+ """判断 ``urlopen`` 相关异常是否宜短暂等待后重试。"""
16
+ if isinstance(exc, urllib.error.HTTPError):
17
+ return exc.code in _RETRYABLE_HTTP_CODES
18
+ if isinstance(exc, TimeoutError):
19
+ return True
20
+ if isinstance(exc, urllib.error.URLError):
21
+ reason = exc.reason
22
+ if isinstance(reason, TimeoutError):
23
+ return True
24
+ if isinstance(reason, (ssl.SSLError, ConnectionResetError, OSError)):
25
+ return True
26
+ return True
27
+ if isinstance(exc, (ssl.SSLError, ConnectionResetError, OSError)):
28
+ return True
29
+ return False
30
+
31
+
32
+ def urlopen_read(
33
+ request: urllib.request.Request,
34
+ *,
35
+ timeout: float,
36
+ max_attempts: int = _DEFAULT_MAX_ATTEMPTS,
37
+ initial_backoff_seconds: float = _DEFAULT_INITIAL_BACKOFF_SECONDS,
38
+ max_backoff_seconds: float = _DEFAULT_MAX_BACKOFF_SECONDS,
39
+ ) -> bytes:
40
+ """``urlopen`` 读响应体;瞬时网络或 TLS 失败时指数退避重试。
41
+
42
+ Args:
43
+ request: 待发送请求。
44
+ timeout: 单次 ``urlopen`` 超时秒数。
45
+ max_attempts: 最多尝试次数(含首次)。
46
+ initial_backoff_seconds: 首次重试前等待秒数。
47
+ max_backoff_seconds: 退避等待上限秒数。
48
+
49
+ Returns:
50
+ 响应体原始字节。
51
+
52
+ Raises:
53
+ urllib.error.HTTPError: 不可重试的 HTTP 状态,或重试耗尽后的 HTTP 错误。
54
+ urllib.error.URLError: 重试耗尽后的网络或 TLS 错误。
55
+ TimeoutError: 重试耗尽后的超时。
56
+ """
57
+ if max_attempts < 1:
58
+ raise ValueError("max_attempts must be >= 1")
59
+
60
+ last_exc: BaseException | None = None
61
+ backoff = initial_backoff_seconds
62
+ for attempt in range(max_attempts):
63
+ try:
64
+ with urllib.request.urlopen(request, timeout=timeout) as response:
65
+ return response.read()
66
+ except BaseException as exc:
67
+ last_exc = exc
68
+ if attempt + 1 >= max_attempts or not is_retryable_urlopen_error(exc):
69
+ raise
70
+ time.sleep(min(backoff, max_backoff_seconds))
71
+ backoff = min(backoff * 2.0, max_backoff_seconds)
72
+
73
+ if last_exc is not None:
74
+ raise last_exc
75
+ raise RuntimeError("urlopen_read ended without response or error")
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import threading
4
5
  import time
5
6
  from collections.abc import Callable
@@ -21,6 +22,9 @@ from ff14_the_hunt.spawn_map.region_fetch import RegionMapFetcher, site_root_fro
21
22
 
22
23
  HuntCrawlCallback = Callable[[HuntCrawlPacket], None]
23
24
 
25
+ _log = logging.getLogger(__name__)
26
+ _FETCH_FAILURE_RETRY_SECONDS = 60.0
27
+
24
28
 
25
29
  class FF14TheHunt:
26
30
  """FF14 狩猎追踪门面:当前对接 [Bear Tracker](https://tracker.beartoolkit.com/timer)。"""
@@ -317,17 +321,33 @@ class FF14TheHunt:
317
321
  if stop_event is None:
318
322
  return
319
323
 
320
- marks = self._scheduler.fetch()
321
- self._emit_crawl(self._make_crawl_packet(marks))
322
-
323
324
  while not stop_event.is_set():
324
- wait_seconds = self._scheduler.seconds_until_next_fetch()
325
- if wait_seconds > 0 and wait_or_stop(stop_event, wait_seconds):
326
- break
327
- if stop_event.is_set():
328
- break
325
+ try:
326
+ self._poll_once_or_wait(stop_event)
327
+ if stop_event.is_set():
328
+ break
329
+ wait_seconds = self._scheduler.seconds_until_next_fetch()
330
+ if wait_seconds > 0 and wait_or_stop(stop_event, wait_seconds):
331
+ break
332
+ except KeyboardInterrupt:
333
+ raise
334
+ except Exception as exc:
335
+ _log.exception("狩猎轮询意外错误,%s 秒后继续:%s", _FETCH_FAILURE_RETRY_SECONDS, exc)
336
+ wait_or_stop(stop_event, _FETCH_FAILURE_RETRY_SECONDS)
337
+
338
+ def _poll_once_or_wait(self, stop_event: threading.Event) -> None:
339
+ try:
329
340
  marks = self._scheduler.fetch()
341
+ except (KeyboardInterrupt, SystemExit):
342
+ raise
343
+ except Exception as exc:
344
+ _log.warning("狩猎爬取失败,%s 秒后重试:%s", _FETCH_FAILURE_RETRY_SECONDS, exc)
345
+ wait_or_stop(stop_event, _FETCH_FAILURE_RETRY_SECONDS)
346
+ return
347
+ try:
330
348
  self._emit_crawl(self._make_crawl_packet(marks))
349
+ except Exception as exc:
350
+ _log.exception("狩猎爬取回调失败,已跳过本轮展示:%s", exc)
331
351
 
332
352
  def _make_crawl_packet(self, marks: list[HuntMarkRecord]) -> HuntCrawlPacket:
333
353
  crawled_at = self._scheduler.last_crawl_at
@@ -4,6 +4,8 @@ import urllib.error
4
4
  import urllib.parse
5
5
  import urllib.request
6
6
 
7
+ from ff14_the_hunt.common.urlopen_retry import urlopen_read
8
+
7
9
 
8
10
  def site_root_from_api_base(base_url: str) -> str:
9
11
  """由 Bear Tracker ``/api`` 根地址推导站点静态资源根。"""
@@ -62,11 +64,7 @@ class RegionMapFetcher:
62
64
  method="GET",
63
65
  )
64
66
  try:
65
- with urllib.request.urlopen(
66
- request,
67
- timeout=self._timeout_seconds,
68
- ) as response:
69
- data = response.read()
67
+ data = urlopen_read(request, timeout=self._timeout_seconds)
70
68
  except urllib.error.HTTPError as exc:
71
69
  detail = exc.read().decode("utf-8", errors="replace")
72
70
  raise RuntimeError(
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-library-ff14-the-hunt"
7
- version = "0.0.4"
7
+ version = "0.0.6"
8
8
  requires-python = ">=3.10"
9
9
  dependencies = [
10
10
  "pydantic>=2.0",
File without changes
@@ -1,6 +1,7 @@
1
1
  import threading
2
2
  import time
3
- from unittest.mock import MagicMock
3
+ import urllib.error
4
+ from unittest.mock import MagicMock, patch
4
5
 
5
6
  from ff14_the_hunt import FF14TheHunt, HuntCrawlPacket, HuntQueryFilter, HuntRankKind
6
7
  from ff14_the_hunt.bear_tracker.timer_theme import build_timer_display
@@ -110,6 +111,31 @@ def test_wait_or_stop() -> None:
110
111
  assert wait_or_stop(stop_event, 1.0) is True
111
112
 
112
113
 
114
+ def test_poll_loop_survives_callback_failure() -> None:
115
+ hunt, _mock_query = _hunt_with_mock_query()
116
+ hunt.on_crawl(lambda _packet: (_ for _ in ()).throw(RuntimeError("sink")))
117
+ stop_event = threading.Event()
118
+ hunt._poll_once_or_wait(stop_event) # type: ignore[attr-defined]
119
+
120
+
121
+ def test_poll_loop_survives_fetch_failure() -> None:
122
+ hunt, mock_query = _hunt_with_mock_query()
123
+ seen: list[HuntCrawlPacket] = []
124
+ hunt.on_crawl(lambda packet: seen.append(packet))
125
+ mock_query.side_effect = [
126
+ urllib.error.URLError("transient"),
127
+ [_sample_mark()],
128
+ ]
129
+
130
+ stop_event = threading.Event()
131
+ with patch("ff14_the_hunt.ff14_the_hunt.wait_or_stop", return_value=False):
132
+ hunt._poll_once_or_wait(stop_event) # type: ignore[attr-defined]
133
+ hunt._poll_once_or_wait(stop_event) # type: ignore[attr-defined]
134
+
135
+ assert mock_query.call_count == 2
136
+ assert len(seen) == 1
137
+
138
+
113
139
  def test_wait_or_stop_wakes_early_on_stop() -> None:
114
140
  stop_event = threading.Event()
115
141
 
@@ -0,0 +1,59 @@
1
+ import ssl
2
+ import urllib.error
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from ff14_the_hunt.common.urlopen_retry import is_retryable_urlopen_error, urlopen_read
8
+
9
+
10
+ def test_is_retryable_urlopen_error_for_ssl_eof() -> None:
11
+ ssl_exc = ssl.SSLEOFError("EOF occurred in violation of protocol")
12
+ url_error = urllib.error.URLError(ssl_exc)
13
+ assert is_retryable_urlopen_error(url_error) is True
14
+
15
+
16
+ def test_is_retryable_urlopen_error_for_http_404() -> None:
17
+ http_error = urllib.error.HTTPError(
18
+ url="https://example.test",
19
+ code=404,
20
+ msg="Not Found",
21
+ hdrs=None,
22
+ fp=None,
23
+ )
24
+ assert is_retryable_urlopen_error(http_error) is False
25
+
26
+
27
+ def test_urlopen_read_retries_transient_failure() -> None:
28
+ request = MagicMock()
29
+ response = MagicMock()
30
+ response.read.return_value = b"ok"
31
+ response.__enter__.return_value = response
32
+ response.__exit__.return_value = False
33
+
34
+ ssl_exc = ssl.SSLEOFError("EOF occurred in violation of protocol")
35
+ url_error = urllib.error.URLError(ssl_exc)
36
+
37
+ with patch("ff14_the_hunt.common.urlopen_retry.time.sleep"):
38
+ with patch(
39
+ "ff14_the_hunt.common.urlopen_retry.urllib.request.urlopen",
40
+ side_effect=[url_error, response],
41
+ ) as mock_urlopen:
42
+ raw = urlopen_read(request, timeout=5.0, max_attempts=3)
43
+
44
+ assert raw == b"ok"
45
+ assert mock_urlopen.call_count == 2
46
+
47
+
48
+ def test_urlopen_read_raises_after_exhausted_retries() -> None:
49
+ request = MagicMock()
50
+ ssl_exc = ssl.SSLEOFError("EOF occurred in violation of protocol")
51
+ url_error = urllib.error.URLError(ssl_exc)
52
+
53
+ with patch("ff14_the_hunt.common.urlopen_retry.time.sleep"):
54
+ with patch(
55
+ "ff14_the_hunt.common.urlopen_retry.urllib.request.urlopen",
56
+ side_effect=url_error,
57
+ ):
58
+ with pytest.raises(urllib.error.URLError):
59
+ urlopen_read(request, timeout=5.0, max_attempts=2)