Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion TM1py/Exceptions/Exceptions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-

# TM1py Exceptions are defined here
from typing import List, Mapping
import re
from typing import List, Mapping, Optional


class TM1pyTimeout(Exception):
Expand Down Expand Up @@ -173,6 +174,75 @@ def __str__(self):
)


class TM1pyNetworkException(TM1pyException):
"""Exception for network/upstream errors where the request never reached TM1.

Raised when a non-JSON response is received (e.g. an HTML block page from
Cloudflare or another WAF/proxy), indicating the issue is infrastructure-level
rather than a TM1 REST API error.

Intentionally does NOT subclass TM1pyRestException so that existing
`except TM1pyRestException` handlers do not swallow infrastructure-level
failures. Both remain siblings under TM1pyException.
"""

def __init__(self, response: str, status_code: int, reason: str, headers: Mapping):
"""
:param response: Response text (e.g. raw HTML from a block page)
:param status_code: HTTP status code
:param reason: Reason phrase
:param headers: HTTP headers
"""
super().__init__(response)
self._status_code = status_code
self._reason = reason
self._headers = headers
self._ray_id = self._extract_cloudflare_ray_id(response, headers)

@staticmethod
def _extract_cloudflare_ray_id(text: str, headers: Mapping) -> Optional[str]:
"""Extract Cloudflare Ray ID from an HTML block page, if present."""
ray_id = headers.get("CF-RAY", "")
if ray_id:
return ray_id
match = re.search(r"Ray ID[:\s]+([a-f0-9]+)", text, re.IGNORECASE)
return match.group(1) if match else None

@property
def status_code(self) -> int:
"""HTTP status code."""
return self._status_code

@property
def reason(self) -> str:
"""Reason phrase."""
return self._reason

@property
def response(self) -> str:
"""Response text."""
return self.message

@property
def headers(self) -> Mapping:
"""HTTP headers."""
return self._headers

@property
def ray_id(self) -> Optional[str]:
"""Cloudflare Ray ID if the block page contained one, else None."""
return self._ray_id

def __str__(self) -> str:
msg = (
f"Network/upstream error — request was likely blocked before reaching TM1. "
f"Status Code: {self._status_code} - Reason: '{self._reason}'"
)
if self._ray_id:
msg += f" - Cloudflare Ray ID: {self._ray_id}"
return msg


class TM1pyWriteFailureException(TM1pyException):
"""Exception for complete failure of write operations."""

Expand Down
2 changes: 2 additions & 0 deletions TM1py/Exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# ruff: noqa: F401
from TM1py.Exceptions.Exceptions import (
TM1pyException,
TM1pyNetworkException,
TM1pyNotAdminException,
TM1pyRestException,
TM1pyTimeout,
TM1pyVersionDeprecationException,
TM1pyVersionException,
TM1pyWriteFailureException,
TM1pyWritePartialFailureException,
Expand Down
16 changes: 13 additions & 3 deletions TM1py/Services/RestService.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
from requests.auth import HTTPBasicAuth
from urllib3._collections import HTTPHeaderDict

from TM1py.Exceptions.Exceptions import TM1pyTimeout, TM1pyVersionDeprecationException
from TM1py.Utils import (
CaseAndSpaceInsensitiveSet,
HTTPAdapterWithSocketOptions,
Expand All @@ -35,7 +34,12 @@

import http.client as http_client

from TM1py.Exceptions import TM1pyRestException
from TM1py.Exceptions import (
TM1pyNetworkException,
TM1pyRestException,
TM1pyTimeout,
TM1pyVersionDeprecationException,
)


class AuthenticationMode(Enum):
Expand Down Expand Up @@ -507,7 +511,7 @@ def _handle_remote_disconnect(
except TM1pyTimeout:
# Re-raise timeout exceptions as-is
raise
except TM1pyRestException:
except (TM1pyRestException, TM1pyNetworkException):
# Re-raise TM1 exceptions as-is
raise
except Exception as retry_error:
Expand Down Expand Up @@ -1153,6 +1157,12 @@ def verify_response(response: Response):
TM1pyException, raises TM1pyException when Code is not 200, 204 etc.
"""
if not response.ok:
content_type = response.headers.get("Content-Type", "")
body_start = response.text.lstrip().lower()
if "text/html" in content_type.lower() or body_start.startswith(("<!doctype", "<html")):
raise TM1pyNetworkException(
response.text, status_code=response.status_code, reason=response.reason, headers=response.headers
)
raise TM1pyRestException(
response.text, status_code=response.status_code, reason=response.reason, headers=response.headers
)
Expand Down
Loading
Loading