-
Notifications
You must be signed in to change notification settings - Fork 0
refactor: Migrate network operations and notifications to use asyncio, aiohttp, aiodns, and aioping. #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…, aiohttp, aiodns, and aioping.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR migrates PingPanda from synchronous network operations to asynchronous I/O using asyncio, aiohttp, aiodns, and aioping. The refactor replaces the ThreadPoolExecutor-based concurrency model with native asyncio concurrency, converting all check functions and notification methods to async/await patterns. This enables more efficient I/O handling and improved scalability when monitoring multiple targets.
Key Changes
- Replaced synchronous libraries (requests, pythonping, socket.gethostbyname) with async alternatives (aiohttp, aioping, aiodns)
- Converted all check methods and notification sending to async/await patterns with
asyncio.gather()for concurrent execution - Updated test suite to use pytest-asyncio with AsyncMock for testing async functions
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| requirements.txt | Updated dependencies to async libraries (aiohttp, aiodns, aioping), added pytest-asyncio for tests |
| pingpanda.py | Wrapped monitor.run() in asyncio.run() to create event loop for async execution |
| pingpanda_core/app.py | Removed ThreadPoolExecutor, added aiohttp.ClientSession and aiodns.DNSResolver initialization, converted run() and cleanup to async |
| pingpanda_core/checks.py | Converted all check classes (DNS, Ping, Website, SSL) to async methods using aiodns, aioping, and aiohttp with asyncio.gather() for parallel execution |
| pingpanda_core/notifications.py | Converted notification methods to async using aiohttp for webhook delivery with asyncio.gather() for parallel notifications |
| tests/test_notifications.py | Updated test to use @pytest.mark.asyncio and await async notification calls |
| tests/test_cli.py | Updated DummyMonitor mock to have async run() method |
| tests/test_checks.py | Converted all tests to async with @pytest.mark.asyncio, updated mocks to use AsyncMock for async functions |
pingpanda_core/checks.py
Outdated
| asyncio.create_task(self.app.send_notification( | ||
| f"IP {ip} is flapping (>{self.app.flap_threshold} status changes in {self.app.flap_window_seconds}s)", | ||
| status="error", | ||
| check_type="Flapping", | ||
| target=ip, | ||
| ) | ||
| )) |
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using asyncio.create_task() without storing or awaiting the task can lead to unhandled exceptions and the notification may not complete before the application shuts down. The task should either be awaited (by making _update_stats async) or stored and awaited later during cleanup. Additionally, if an exception occurs in the task, it will be silently ignored unless the task is awaited or has an exception handler.
pingpanda_core/checks.py
Outdated
| website, | ||
| response.status_code, | ||
| duration_ms, | ||
| async with session.get(website, timeout=10) as response: |
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same issue as in notifications.py: the timeout=10 parameter should be wrapped in an aiohttp.ClientTimeout object. This should be: session.get(website, timeout=aiohttp.ClientTimeout(total=10))
| async with session.get(website, timeout=10) as response: | |
| async with session.get(website, timeout=aiohttp.ClientTimeout(total=10)) as response: |
pingpanda_core/app.py
Outdated
|
|
||
| # Initialize async resources | ||
| self.http_session = aiohttp.ClientSession() | ||
| self.dns_resolver = aiodns.DNSResolver(loop=asyncio.get_running_loop()) |
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The aiodns.DNSResolver(loop=asyncio.get_running_loop()) uses the deprecated loop parameter. In modern asyncio (Python 3.10+), the loop parameter is deprecated and should be removed. Use aiodns.DNSResolver() instead and let it automatically use the current event loop.
| self.dns_resolver = aiodns.DNSResolver(loop=asyncio.get_running_loop()) | |
| self.dns_resolver = aiodns.DNSResolver() |
pingpanda_core/checks.py
Outdated
| resolver = getattr(app, "dns_resolver", None) | ||
| if not resolver: | ||
| # Fallback if not initialized in app (though it should be) | ||
| resolver = aiodns.DNSResolver(loop=asyncio.get_running_loop()) |
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same issue as in app.py: the loop=asyncio.get_running_loop() parameter is deprecated. Use aiodns.DNSResolver() without the loop parameter.
| resolver = aiodns.DNSResolver(loop=asyncio.get_running_loop()) | |
| resolver = aiodns.DNSResolver() |
pingpanda_core/notifications.py
Outdated
| # We could log partial failures here if needed | ||
|
|
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The return_exceptions=True in asyncio.gather() silently swallows exceptions without logging them. If any notification fails with an exception (not just returning False), the failure will be invisible to users. Consider iterating through results and logging any exceptions that occurred, or remove return_exceptions=True to let exceptions propagate.
| # We could log partial failures here if needed | |
| # Log any notification channel failures | |
| channels = [] | |
| if self.settings.slack_webhook_url: | |
| channels.append("Slack") | |
| if self.settings.teams_webhook_url: | |
| channels.append("Teams") | |
| if self.settings.discord_webhook_url: | |
| channels.append("Discord") | |
| for idx, result in enumerate(results): | |
| if isinstance(result, Exception): | |
| self.logger.error( | |
| f"Notification to {channels[idx]} failed: {result!r}" | |
| ) |
pingpanda_core/checks.py
Outdated
| # aioping returns delay in seconds | ||
| delay = await aioping.ping(ip, timeout=2) | ||
|
|
||
| elapsed = time.perf_counter() - start_time |
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Variable elapsed is not used.
| elapsed = time.perf_counter() - start_time |
| self.logger.debug("Notification suppressed; no webhook endpoints configured") | ||
| return | ||
|
|
||
| results = await asyncio.gather(*tasks, return_exceptions=True) |
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Variable results is not used.
pingpanda_core/checks.py
Outdated
| from dataclasses import dataclass | ||
| from datetime import datetime | ||
| from typing import Any, Dict, Optional | ||
| from typing import Any, Dict, Optional, List |
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import of 'List' is not used.
| from typing import Any, Dict, Optional, List | |
| from typing import Any, Dict, Optional |
| @@ -1,5 +1,5 @@ | |||
| import sys | |||
|
|
|||
| import asyncio | |||
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import of 'asyncio' is not used.
| import asyncio |
| monitor.run() | ||
| try: | ||
| asyncio.run(monitor.run()) | ||
| except KeyboardInterrupt: |
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
'except' clause does nothing but pass and there is no explanatory comment.
| except KeyboardInterrupt: | |
| except KeyboardInterrupt: | |
| # Allow clean exit on Ctrl+C without traceback |
…and notifications.
and Discord notifications.
…cation HTTP requests.
…own on `KeyboardInterrupt`.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 7 out of 8 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
tests/test_cli.py:2
- Import of 'asyncio' is not used.
import asyncio
pingpanda_core/checks.py
Outdated
| elapsed = time.perf_counter() - start_time | ||
| duration_ms = response_list.rtt_avg_ms | ||
| success = False | ||
| success = False |
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate variable assignment. Remove one of the success = False declarations.
| success = False |
| @@ -1,5 +1,5 @@ | |||
| import sys | |||
|
|
|||
| import asyncio | |||
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The asyncio import is unused in this test file. The test itself doesn't directly use asyncio - it's handled by pingpanda.main(). Remove this unused import.
| import asyncio |
| if attempt < app.retry_count - 1: | ||
| await asyncio.sleep(1) |
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Inconsistent retry delay behavior. In DNS check, retry delay only happens if not on the last attempt (if attempt < app.retry_count - 1), but other checks follow the same pattern. However, the original synchronous code used unconditional time.sleep(1). Consider whether this conditional is intentional or if it should remain unconditional for consistency with original behavior.
| if attempt < app.retry_count - 1: | |
| await asyncio.sleep(1) | |
| await asyncio.sleep(1) |
| if attempt < app.retry_count - 1: | ||
| await asyncio.sleep(1) |
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Same as DNS check - the retry delay is now conditional on not being the last attempt, which differs from the original synchronous code that always slept between attempts. This changes retry timing behavior. Verify this is intentional.
| if attempt < app.retry_count - 1: | |
| await asyncio.sleep(1) | |
| await asyncio.sleep(1) |
| monitor.run() | ||
| try: | ||
| asyncio.run(monitor.run()) | ||
| except KeyboardInterrupt: |
Copilot
AI
Nov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
'except' clause does nothing but pass and there is no explanatory comment.
async all the things?