Skip to content

Commit 9d3a4ad

Browse files
committed
v1.0.3: Improved connection reliability and error handling
## Changes - Better connection error handling with specific exception types (DNS, SSL, timeout, disconnect) - Added User-Agent headers to prevent WAF/CDN blocking - Increased timeouts (30s total, 10s connect) for slower networks - Added connectivity diagnostic method for troubleshooting - Enhanced error logging with error_type classification ## Fixed - Intermittent connection failures where users could reach ultracard.io in browser but not through HA
1 parent e85c25f commit 9d3a4ad

File tree

4 files changed

+226
-14
lines changed

4 files changed

+226
-14
lines changed

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Changelog
2+
3+
All notable changes to Ultra Card Pro Cloud will be documented in this file.
4+
5+
## [1.0.3] - 2024-12-22
6+
7+
### Improved
8+
- **Better Connection Error Handling**: Added specific exception handling for different connection error types (DNS, SSL/TLS, timeout, server disconnection) with helpful error messages to aid troubleshooting
9+
- **User-Agent Headers**: All API requests now include proper User-Agent headers to prevent WAF/CDN blocking
10+
- **Increased Timeouts**: Extended request timeout from 15s to 30s with a separate 10s connection timeout for more reliable connections on slower networks
11+
- **Connectivity Diagnostics**: Added `async_test_connectivity()` method for diagnosing connection issues (tests DNS, SSL, API, and auth separately)
12+
- **Enhanced Error Logging**: Error responses now include `error_type` field for easier issue categorization
13+
14+
### Fixed
15+
- Intermittent connection failures where users could reach ultracard.io in browser but not through Home Assistant
16+
17+
## [1.0.2] - 2024-12-15
18+
19+
### Initial Release
20+
- JWT authentication with ultracard.io
21+
- Subscription status tracking
22+
- Token refresh and auto-renewal
23+
- Home Assistant sensor integration

custom_components/ultra_card_pro_cloud/coordinator.py

Lines changed: 201 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
RATE_LIMIT_DELAY = 5 # seconds for 429 responses
3232
DEFAULT_TOKEN_EXPIRY_DAYS = 180 # Match JWT Auth Pro default
3333

34+
# Connection settings
35+
USER_AGENT = "HomeAssistant/UltraCardProCloud/1.0"
36+
REQUEST_TIMEOUT = 30 # Total timeout in seconds
37+
CONNECT_TIMEOUT = 10 # Connection establishment timeout
38+
3439

3540
def parse_jwt_expiry(token: str) -> int | None:
3641
"""Parse the expiry timestamp from a JWT token.
@@ -71,6 +76,22 @@ def parse_jwt_expiry(token: str) -> int | None:
7176
return None
7277

7378

79+
def _get_timeout() -> aiohttp.ClientTimeout:
80+
"""Get standard timeout configuration."""
81+
return aiohttp.ClientTimeout(total=REQUEST_TIMEOUT, connect=CONNECT_TIMEOUT)
82+
83+
84+
def _get_headers(token: str | None = None) -> dict[str, str]:
85+
"""Get standard request headers with User-Agent."""
86+
headers = {
87+
"User-Agent": USER_AGENT,
88+
"Accept": "application/json",
89+
}
90+
if token:
91+
headers["Authorization"] = f"Bearer {token}"
92+
return headers
93+
94+
7495
class UltraCardProCloudCoordinator(DataUpdateCoordinator):
7596
"""Class to manage fetching Ultra Card Pro Cloud data."""
7697

@@ -155,21 +176,106 @@ async def _async_update_data(self) -> dict[str, Any]:
155176
_LOGGER.debug("✅ Data update successful")
156177
return result
157178

158-
except Exception as err:
179+
except aiohttp.ClientConnectorError as err:
180+
# DNS resolution failed or connection refused
181+
self._auth_failure_count += 1
182+
_LOGGER.error(
183+
"❌ Connection error to Ultra Card API: %s "
184+
"(This is usually a DNS or network issue - verify ultracard.io is reachable from your HA instance)",
185+
err
186+
)
187+
return {
188+
"authenticated": False,
189+
"error": f"Connection failed: Unable to reach ultracard.io - {err}",
190+
"error_type": "connection",
191+
}
192+
except aiohttp.ClientSSLError as err:
193+
# SSL/TLS certificate issues
194+
self._auth_failure_count += 1
195+
_LOGGER.error(
196+
"❌ SSL/TLS error connecting to Ultra Card API: %s "
197+
"(Certificate validation failed - this may be a network proxy or firewall issue)",
198+
err
199+
)
200+
return {
201+
"authenticated": False,
202+
"error": f"SSL certificate error: {err}",
203+
"error_type": "ssl",
204+
}
205+
except asyncio.TimeoutError:
206+
# Request timed out
159207
self._auth_failure_count += 1
160-
_LOGGER.error("❌ Error communicating with Ultra Card API: %s", err)
208+
_LOGGER.error(
209+
"❌ Timeout connecting to Ultra Card API "
210+
"(Server did not respond within %d seconds - check your internet connection or try again later)",
211+
REQUEST_TIMEOUT
212+
)
213+
return {
214+
"authenticated": False,
215+
"error": f"Connection timed out after {REQUEST_TIMEOUT} seconds",
216+
"error_type": "timeout",
217+
}
218+
except aiohttp.ServerDisconnectedError as err:
219+
# Server closed connection unexpectedly
220+
self._auth_failure_count += 1
221+
_LOGGER.error(
222+
"❌ Server disconnected during request: %s "
223+
"(The server closed the connection - this may be temporary, try again)",
224+
err
225+
)
226+
return {
227+
"authenticated": False,
228+
"error": f"Server disconnected: {err}",
229+
"error_type": "disconnected",
230+
}
231+
except aiohttp.ClientResponseError as err:
232+
# HTTP error response
233+
self._auth_failure_count += 1
234+
_LOGGER.error(
235+
"❌ HTTP error from Ultra Card API: %s %s",
236+
err.status, err.message
237+
)
238+
return {
239+
"authenticated": False,
240+
"error": f"HTTP {err.status}: {err.message}",
241+
"error_type": "http_error",
242+
}
243+
except UpdateFailed as err:
244+
# Our own UpdateFailed exceptions
245+
self._auth_failure_count += 1
246+
_LOGGER.error("❌ Update failed: %s", err)
161247

162248
# If we've failed multiple times, clear tokens to force re-auth
163249
if self._auth_failure_count >= 3:
164-
_LOGGER.warning("⚠️ Multiple auth failures, clearing tokens for fresh start")
250+
_LOGGER.warning("⚠️ Multiple auth failures (%d), clearing tokens for fresh start", self._auth_failure_count)
165251
self._jwt_token = None
166252
self._refresh_token = None
167253
self._token_expires_at = 0
168254

169-
# Return unauthenticated state instead of raising
170255
return {
171256
"authenticated": False,
172257
"error": str(err),
258+
"error_type": "update_failed",
259+
}
260+
except Exception as err:
261+
# Catch-all for unexpected errors
262+
self._auth_failure_count += 1
263+
_LOGGER.error(
264+
"❌ Unexpected error communicating with Ultra Card API: %s (%s)",
265+
err, type(err).__name__
266+
)
267+
268+
# If we've failed multiple times, clear tokens to force re-auth
269+
if self._auth_failure_count >= 3:
270+
_LOGGER.warning("⚠️ Multiple auth failures (%d), clearing tokens for fresh start", self._auth_failure_count)
271+
self._jwt_token = None
272+
self._refresh_token = None
273+
self._token_expires_at = 0
274+
275+
return {
276+
"authenticated": False,
277+
"error": f"{type(err).__name__}: {err}",
278+
"error_type": "unknown",
173279
}
174280

175281
async def _authenticate(self) -> None:
@@ -187,7 +293,8 @@ async def _authenticate(self) -> None:
187293
async with self.session.post(
188294
url,
189295
json={"username": username, "password": password},
190-
timeout=aiohttp.ClientTimeout(total=15),
296+
headers=_get_headers(),
297+
timeout=_get_timeout(),
191298
) as response:
192299
response_text = await response.text()
193300
_LOGGER.debug("📥 Auth response status: %s", response.status)
@@ -305,11 +412,14 @@ async def _refresh_jwt_token(self) -> None:
305412
try:
306413
# JWT Auth Pro expects the refresh token in Authorization header as Bearer token
307414
# OR in the request body - we'll try both approaches
415+
headers = _get_headers()
416+
headers["Authorization"] = f"Bearer {self._refresh_token}"
417+
308418
async with self.session.post(
309419
url,
310420
json={"refresh_token": self._refresh_token},
311-
headers={"Authorization": f"Bearer {self._refresh_token}"},
312-
timeout=aiohttp.ClientTimeout(total=15),
421+
headers=headers,
422+
timeout=_get_timeout(),
313423
) as response:
314424
response_text = await response.text()
315425
_LOGGER.debug("📥 Refresh response status: %s", response.status)
@@ -398,8 +508,8 @@ async def _fetch_user_profile(self) -> dict[str, Any]:
398508
try:
399509
async with self.session.get(
400510
url,
401-
headers={"Authorization": f"Bearer {self._jwt_token}"},
402-
timeout=aiohttp.ClientTimeout(total=15),
511+
headers=_get_headers(self._jwt_token),
512+
timeout=_get_timeout(),
403513
) as response:
404514
response_text = await response.text()
405515
_LOGGER.debug("📥 User profile response status: %s", response.status)
@@ -458,8 +568,8 @@ async def _fetch_subscription(self) -> dict[str, Any]:
458568
try:
459569
async with self.session.get(
460570
url,
461-
headers={"Authorization": f"Bearer {self._jwt_token}"},
462-
timeout=aiohttp.ClientTimeout(total=15),
571+
headers=_get_headers(self._jwt_token),
572+
timeout=_get_timeout(),
463573
) as response:
464574
response_text = await response.text()
465575
_LOGGER.debug("📥 Subscription response status: %s", response.status)
@@ -505,6 +615,85 @@ async def _fetch_subscription(self) -> dict[str, Any]:
505615

506616
raise UpdateFailed("Failed to fetch subscription after all retries")
507617

618+
async def async_test_connectivity(self) -> dict[str, Any]:
619+
"""Test connectivity to ultracard.io for diagnostics.
620+
621+
Returns a dict with test results for each stage:
622+
- dns: Whether DNS resolution succeeded
623+
- ssl: Whether SSL/TLS handshake succeeded
624+
- api: Whether the API responded
625+
- auth: Whether authentication works (if credentials provided)
626+
"""
627+
results = {
628+
"dns": False,
629+
"ssl": False,
630+
"api": False,
631+
"auth": False,
632+
"errors": [],
633+
}
634+
635+
# Test 1: Basic connectivity (DNS + SSL)
636+
try:
637+
async with self.session.get(
638+
"https://ultracard.io/",
639+
timeout=aiohttp.ClientTimeout(total=10, connect=5),
640+
headers={"User-Agent": USER_AGENT},
641+
) as resp:
642+
results["dns"] = True
643+
results["ssl"] = True
644+
if resp.status < 500:
645+
results["api"] = True
646+
else:
647+
results["errors"].append(f"Server returned status {resp.status}")
648+
except aiohttp.ClientConnectorError as e:
649+
results["errors"].append(f"DNS/Connection failed: {e}")
650+
except aiohttp.ClientSSLError as e:
651+
results["dns"] = True # DNS worked if we got to SSL
652+
results["errors"].append(f"SSL/TLS failed: {e}")
653+
except asyncio.TimeoutError:
654+
results["errors"].append("Connection timed out")
655+
except Exception as e:
656+
results["errors"].append(f"Unexpected error: {type(e).__name__}: {e}")
657+
658+
# Test 2: API endpoint accessibility
659+
if results["ssl"]:
660+
try:
661+
url = f"{API_BASE_URL}{JWT_ENDPOINT}"
662+
async with self.session.get(
663+
url,
664+
timeout=aiohttp.ClientTimeout(total=10, connect=5),
665+
headers={"User-Agent": USER_AGENT},
666+
) as resp:
667+
# JWT endpoint should return 405 for GET (method not allowed) or 200
668+
# Either indicates the API is reachable
669+
if resp.status in (200, 405, 401):
670+
results["api"] = True
671+
else:
672+
results["errors"].append(f"API endpoint returned unexpected status {resp.status}")
673+
except Exception as e:
674+
results["errors"].append(f"API test failed: {type(e).__name__}: {e}")
675+
676+
# Test 3: Authentication (if we have credentials)
677+
if results["api"] and self._jwt_token:
678+
results["auth"] = True
679+
elif results["api"]:
680+
try:
681+
# Try to authenticate
682+
await self._authenticate()
683+
if self._jwt_token:
684+
results["auth"] = True
685+
except Exception as e:
686+
results["errors"].append(f"Authentication test failed: {e}")
687+
688+
_LOGGER.info(
689+
"🔍 Connectivity test results - DNS: %s, SSL: %s, API: %s, Auth: %s",
690+
results["dns"], results["ssl"], results["api"], results["auth"]
691+
)
692+
if results["errors"]:
693+
_LOGGER.warning("⚠️ Connectivity test errors: %s", results["errors"])
694+
695+
return results
696+
508697
async def async_logout(self) -> None:
509698
"""Logout and invalidate tokens."""
510699
_LOGGER.debug("🚪 Logging out from Ultra Card Pro Cloud")
@@ -521,7 +710,7 @@ async def async_logout(self) -> None:
521710
try:
522711
async with self.session.post(
523712
url,
524-
headers={"Authorization": f"Bearer {self._jwt_token}"},
713+
headers=_get_headers(self._jwt_token),
525714
timeout=aiohttp.ClientTimeout(total=5),
526715
) as response:
527716
if 200 <= response.status < 300:

custom_components/ultra_card_pro_cloud/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@
1111
"iot_class": "cloud_polling",
1212
"issue_tracker": "https://github.com/WJDDesigns/ultra-card-pro-cloud/issues",
1313
"requirements": [],
14-
"version": "1.0.2"
14+
"version": "1.0.3"
1515
}

version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
This is the single source of truth for version information.
44
"""
55

6-
__version__ = "1.0.2"
6+
__version__ = "1.0.3"

0 commit comments

Comments
 (0)