Skip to content

Conversation

@KingPin
Copy link
Owner

@KingPin KingPin commented Nov 24, 2025

async all the things?

Copy link
Contributor

Copilot AI left a 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

Comment on lines 214 to 219
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,
)
))
Copy link

Copilot AI Nov 24, 2025

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.

Copilot uses AI. Check for mistakes.
website,
response.status_code,
duration_ms,
async with session.get(website, timeout=10) as response:
Copy link

Copilot AI Nov 24, 2025

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))

Suggested change
async with session.get(website, timeout=10) as response:
async with session.get(website, timeout=aiohttp.ClientTimeout(total=10)) as response:

Copilot uses AI. Check for mistakes.

# Initialize async resources
self.http_session = aiohttp.ClientSession()
self.dns_resolver = aiodns.DNSResolver(loop=asyncio.get_running_loop())
Copy link

Copilot AI Nov 24, 2025

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.

Suggested change
self.dns_resolver = aiodns.DNSResolver(loop=asyncio.get_running_loop())
self.dns_resolver = aiodns.DNSResolver()

Copilot uses AI. Check for mistakes.
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())
Copy link

Copilot AI Nov 24, 2025

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.

Suggested change
resolver = aiodns.DNSResolver(loop=asyncio.get_running_loop())
resolver = aiodns.DNSResolver()

Copilot uses AI. Check for mistakes.
Comment on lines 79 to 80
# We could log partial failures here if needed

Copy link

Copilot AI Nov 24, 2025

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.

Suggested change
# 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}"
)

Copilot uses AI. Check for mistakes.
# aioping returns delay in seconds
delay = await aioping.ping(ip, timeout=2)

elapsed = time.perf_counter() - start_time
Copy link

Copilot AI Nov 24, 2025

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.

Suggested change
elapsed = time.perf_counter() - start_time

Copilot uses AI. Check for mistakes.
self.logger.debug("Notification suppressed; no webhook endpoints configured")
return

results = await asyncio.gather(*tasks, return_exceptions=True)
Copy link

Copilot AI Nov 24, 2025

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.

Copilot uses AI. Check for mistakes.
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, List
Copy link

Copilot AI Nov 24, 2025

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.

Suggested change
from typing import Any, Dict, Optional, List
from typing import Any, Dict, Optional

Copilot uses AI. Check for mistakes.
@@ -1,5 +1,5 @@
import sys

import asyncio
Copy link

Copilot AI Nov 24, 2025

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.

Suggested change
import asyncio

Copilot uses AI. Check for mistakes.
monitor.run()
try:
asyncio.run(monitor.run())
except KeyboardInterrupt:
Copy link

Copilot AI Nov 24, 2025

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.

Suggested change
except KeyboardInterrupt:
except KeyboardInterrupt:
# Allow clean exit on Ctrl+C without traceback

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a 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

elapsed = time.perf_counter() - start_time
duration_ms = response_list.rtt_avg_ms
success = False
success = False
Copy link

Copilot AI Nov 24, 2025

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.

Suggested change
success = False

Copilot uses AI. Check for mistakes.
@@ -1,5 +1,5 @@
import sys

import asyncio
Copy link

Copilot AI Nov 24, 2025

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.

Suggested change
import asyncio

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +96
if attempt < app.retry_count - 1:
await asyncio.sleep(1)
Copy link

Copilot AI Nov 24, 2025

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.

Suggested change
if attempt < app.retry_count - 1:
await asyncio.sleep(1)
await asyncio.sleep(1)

Copilot uses AI. Check for mistakes.
Comment on lines +181 to +182
if attempt < app.retry_count - 1:
await asyncio.sleep(1)
Copy link

Copilot AI Nov 24, 2025

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.

Suggested change
if attempt < app.retry_count - 1:
await asyncio.sleep(1)
await asyncio.sleep(1)

Copilot uses AI. Check for mistakes.
monitor.run()
try:
asyncio.run(monitor.run())
except KeyboardInterrupt:
Copy link

Copilot AI Nov 24, 2025

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.

Copilot uses AI. Check for mistakes.
@KingPin KingPin merged commit b966cd0 into main Nov 25, 2025
5 checks passed
@KingPin KingPin deleted the async-2025-1 branch November 25, 2025 17:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants