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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.0.4] - 2026-05-18

### Fixed

- **Authentication with username/password on instances with REST++ authentication enabled** — connections constructed with only `username` and `password` (no `apiToken`) now work against any TigerGraph instance that has REST++ authentication enabled. The library automatically mints a token and retries the request when the server signals that a token is required.
- **`getToken()` correctly applies the token to the connection** — the connection's `apiToken` is now the token string. Previously it was stored as an internal tuple `(token, expiration)`, leaving every subsequent request unauthenticated.
- **`refreshToken()` correctly applies the refreshed token** — the connection is now updated to use the refreshed token. Previously the call returned successfully but the connection silently continued using the old token.
- **Token mint prefers the modern endpoint** — token requests try the TigerGraph 4.x endpoint (`POST /gsql/v1/tokens`) first and fall back to the legacy endpoint (`POST /restpp/requesttoken`) on failure, matching the order already used by the async client.

---

## [2.0.3] - 2026-04-09

### New Features
Expand Down
2 changes: 1 addition & 1 deletion pyTigerGraph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
try:
__version__ = _pkg_version("pyTigerGraph")
except PackageNotFoundError:
__version__ = "2.0.2"
__version__ = "2.0.4"

__license__ = "Apache 2"

Expand Down
46 changes: 46 additions & 0 deletions pyTigerGraph/common/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,52 @@

logger = logging.getLogger(__name__)


def _is_auth_failure_response(body) -> bool:
"""True if `body` (a parsed JSON dict, or anything else) is a TigerGraph
error response signaling that the request lacks valid authentication.
Multiple shapes are accepted, since REST++ and GSQL surface auth failures
differently:

* REST++ `code == "REST-10016"` ("Access Denied because the input
token = '' is empty or too short").
* Plain ``{"error": true, "message": "Authentication failed."}`` —
returned by GSQL endpoints with no `code` field when the request
carries no credentials.
"""
if not isinstance(body, dict) or not body.get("error"):
return False
if body.get("code") == "REST-10016":
return True
msg = body.get("message")
return isinstance(msg, str) and "Authentication failed" in msg


def _is_endpoint_not_found(exc: Exception) -> bool:
"""True if `exc` looks like the server reporting that the requested URL
isn't served. Two shapes are accepted:

* HTTP 404. Surfaced as ``requests.exceptions.HTTPError`` on the sync
path and ``aiohttp.ClientResponseError`` on the async path. We
duck-type on the status attribute to avoid importing either lib here.
* ``TigerGraphException`` carrying REST++ code ``REST-1000`` with the
canonical "Endpoint is not found" message in its first argument.
"""
status = None
response = getattr(exc, "response", None)
if response is not None:
status = getattr(response, "status_code", None)
if status is None:
status = getattr(exc, "status", None)
if status == 404:
return True
if isinstance(exc, TigerGraphException):
if getattr(exc, "code", None) != "REST-1000":
return False
msg = exc.args[0] if exc.args else ""
return isinstance(msg, str) and "Endpoint is not found" in msg
return False

def _parse_get_secrets(response: str) -> Dict[str, str]:
secrets_dict = {}
lines = response.split("\n")
Expand Down
40 changes: 25 additions & 15 deletions pyTigerGraph/pyTigerGraphAuth.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
_parse_get_secrets,
_parse_create_secret,
_prep_token_request,
_parse_token_response
_parse_token_response,
_is_endpoint_not_found,
)
from pyTigerGraph.common.exception import TigerGraphException
from pyTigerGraph.pyTigerGraphGSQL import pyTigerGraphGSQL
Expand Down Expand Up @@ -348,24 +349,29 @@ def _token(self, secret: str = None, lifetime: int = None, token: str = None, _m
if _method:
method = _method

# Try using TG 3.x endpoint first, if url not found then try <4.1 endpoint
# Try TG 4.x endpoint first (POST /gsql/v1/tokens); fall back to the
# legacy TG 3.x endpoint (POST /restpp/requesttoken) only when the
# modern endpoint is genuinely absent on this server. "Endpoint not
# found" can surface either as an HTTP 404 or as a 2xx response with
# REST-1000 in the JSON body — _is_endpoint_not_found handles both.
try:
res = self._req(
method, alt_url, authMode=authMode, data=alt_data, resKey=None)
mainVer = 3
except:
try:
res = self._req(method, url, authMode=authMode,
res = self._req(method, url, authMode=authMode,
data=data, resKey=None, jsonData=True)
mainVer = 4
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
mainVer = 4
except Exception as e:
if not _is_endpoint_not_found(e):
raise
try:
res = self._req(
method, alt_url, authMode=authMode, data=alt_data, resKey=None)
mainVer = 3
except Exception as e2:
if _is_endpoint_not_found(e2):
raise TigerGraphException(
Comment thread
chengbiao-jin marked this conversation as resolved.
"Error requesting token. Check if the connection's graphname is correct and that REST authentication is enabled.",
404
)
else:
raise e
raise

# uses mainVer instead of _versionGreaterThan4_0 since you need a token for verson checking
return res, mainVer
Expand Down Expand Up @@ -420,10 +426,11 @@ def getToken(self,
mainVer,
self.base64_credential
)
self.apiToken = token
self.apiToken = token[0] if isinstance(token, tuple) else token
self.authHeader = auth_header
self.authMode = "token"
self._token_source = "generated"
self._refresh_auth_headers()

logger.debug("exit: getToken")
return token
Expand Down Expand Up @@ -480,7 +487,10 @@ def refreshToken(self, secret: str = None, setToken: bool = True, lifetime: int
token = self.apiToken
res, mainVer = self._token(secret, lifetime, token, "PUT")

newToken = _parse_token_response(res, setToken, mainVer, self.base64_credential)
newToken, auth_header = _parse_token_response(res, setToken, mainVer, self.base64_credential)
self.apiToken = newToken[0] if isinstance(newToken, tuple) else newToken
self.authHeader = auth_header
self._refresh_auth_headers()

logger.debug("exit: refreshToken")

Expand Down
38 changes: 26 additions & 12 deletions pyTigerGraph/pyTigerGraphBase.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from typing import Union
from urllib.parse import urlparse

from pyTigerGraph.common.auth import _is_auth_failure_response
from pyTigerGraph.common.exception import TigerGraphException
from pyTigerGraph.common.base import PyTigerGraphCore

Expand Down Expand Up @@ -222,14 +223,33 @@ def _req(self, method: str, url: str, authMode: str = "token", headers: dict = N
conn_err = e

if res is not None:
# Auto-refresh token on 401 if the token was generated by getToken()
if res.status_code == 401 and getattr(self, "_token_source", None) == "generated":
# Auto-mint/refresh token and retry once when the server signals the request
# needs a Bearer token. Two trigger shapes:
# * HTTP 401
# * an auth-failure JSON body — REST++ REST-10016 or a GSQL
# `{"error":true,"message":"Authentication failed."}` shape.
# In both cases, only retry when the connection's credentials are ours to
# manage (no user-supplied apiToken/jwtToken). A user-supplied token is
# treated as deliberate: we surface the auth error rather than silently
# replacing the user's token with a freshly-minted one.
needs_token_retry = False
if (not getattr(self, "_refreshing_token", False)
and getattr(self, "_token_source", None) != "user"):
if res.status_code == 401:
needs_token_retry = True
elif res.content:
try:
_body = json.loads(res.content)
except (json.decoder.JSONDecodeError, ValueError):
_body = None
if _is_auth_failure_response(_body):
needs_token_retry = True
if needs_token_retry:
with self._token_refresh_lock:
if not getattr(self, "_refreshing_token", False):
try:
self._refreshing_token = True
self.getToken()
self._refresh_auth_headers()
finally:
self._refreshing_token = False
_headers, _data, _ = self._prep_req(authMode, headers, url, method, data)
Expand Down Expand Up @@ -583,15 +603,9 @@ def _version_greater_than_4_0(self) -> bool:
if hasattr(self, '_cached_version_greater_than_4_0'):
return self._cached_version_greater_than_4_0

# Cache not set, fetch version and cache the result
try:
version = self.getVer().split('.')
except TigerGraphException as e:
if e.code == "REST-10016":
self.getToken()
version = self.getVer().split('.')
else:
raise e
# The generic _req-level retry handles REST-10016 (mint a token, retry once),
# so no ad-hoc recovery is needed here.
version = self.getVer().split('.')
result = version[0] >= "4" and version[1] > "0"
self._cached_version_greater_than_4_0 = result
return result
Expand Down
28 changes: 21 additions & 7 deletions pyTigerGraph/pytgasync/pyTigerGraphAuth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
_parse_get_secrets,
_parse_create_secret,
_prep_token_request,
_parse_token_response
_parse_token_response,
_is_endpoint_not_found,
)
from pyTigerGraph.pytgasync.pyTigerGraphGSQL import AsyncPyTigerGraphGSQL

Expand Down Expand Up @@ -342,16 +343,25 @@ async def _token(self, secret: str = None, lifetime: int = None, token=None, _me
if _method:
method = _method

# Try using TG 4.1 endpoint first, if url not found then try <4.1 endpoint
# Try TG 4.x endpoint first; fall back to the legacy TG 3.x endpoint
# only when the modern endpoint is genuinely absent on this server.
# See sync _token for the rationale and the helper's contract.
try:
res = await self._req(method, url, authMode=authMode, data=data, resKey=None, jsonData=True)
mainVer = 4
except:
except Exception as e:
if not _is_endpoint_not_found(e):
raise
try:
res = await self._req(method, alt_url, authMode=authMode, data=alt_data, resKey=None)
mainVer = 3
except:
raise TigerGraphException("Error requesting token. Check if the connection's graphname is correct.", 400)
except Exception as e2:
if _is_endpoint_not_found(e2):
raise TigerGraphException(
"Error requesting token. Check if the connection's graphname is correct and that REST authentication is enabled.",
404
)
raise

# uses mainVer instead of _versionGreaterThan4_0 since you need a token for verson checking
return res, mainVer
Expand All @@ -368,10 +378,11 @@ async def getToken(self, secret: str = None, setToken: bool = True, lifetime: in
self.base64_credential
)

self.apiToken = token
self.apiToken = token[0] if isinstance(token, tuple) else token
self.authHeader = auth_header
self.authMode = "token"
self._token_source = "generated"
self._refresh_auth_headers()

logger.debug("exit: getToken")
return token
Expand All @@ -389,7 +400,10 @@ async def refreshToken(self, secret: str = None, setToken: bool = True, lifetime
if not token:
token = self.apiToken
res, mainVer = await self._token(secret=secret, lifetime=lifetime, token=token, _method="PUT")
newToken = _parse_token_response(res, setToken, mainVer)
newToken, auth_header = _parse_token_response(res, setToken, mainVer, self.base64_credential)
self.apiToken = newToken[0] if isinstance(newToken, tuple) else newToken
self.authHeader = auth_header
self._refresh_auth_headers()

logger.debug("exit: refreshToken")
return newToken
Expand Down
31 changes: 19 additions & 12 deletions pyTigerGraph/pytgasync/pyTigerGraphBase.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from typing import Optional, Union
from urllib.parse import urlparse

from pyTigerGraph.common.auth import _is_auth_failure_response
from pyTigerGraph.common.base import PyTigerGraphCore
from pyTigerGraph.common.exception import TigerGraphException

Expand Down Expand Up @@ -163,14 +164,26 @@ async def _req(self, method: str, url: str, authMode: str = "token", headers: di
conn_err = e

if resp is not None:
# Auto-refresh token on 401 if the token was generated by getToken()
if status == 401 and getattr(self, "_token_source", None) == "generated":
# Auto-mint/refresh token and retry once when the server signals the request
# needs a Bearer token. See sync _req for full rationale.
needs_token_retry = False
if (not getattr(self, "_refreshing_token", False)
and getattr(self, "_token_source", None) != "user"):
if status == 401:
needs_token_retry = True
elif body:
try:
_body = json.loads(body)
except (json.decoder.JSONDecodeError, ValueError):
_body = None
if _is_auth_failure_response(_body):
needs_token_retry = True
if needs_token_retry:
async with self._token_refresh_lock:
if not getattr(self, "_refreshing_token", False):
try:
self._refreshing_token = True
await self.getToken()
self._refresh_auth_headers()
finally:
self._refreshing_token = False
_headers, _data, _ = self._prep_req(authMode, headers, url, method, data)
Expand Down Expand Up @@ -554,15 +567,9 @@ async def _version_greater_than_4_0(self) -> bool:
if hasattr(self, '_cached_version_greater_than_4_0'):
return self._cached_version_greater_than_4_0

# Cache not set, fetch version and cache the result
try:
version = (await self.getVer()).split('.')
except TigerGraphException as e:
if e.code == "REST-10016":
await self.getToken()
version = (await self.getVer()).split('.')
else:
raise e
# The generic _req-level retry handles REST-10016 (mint a token, retry once),
# so no ad-hoc recovery is needed here.
version = (await self.getVer()).split('.')
result = version[0] >= "4" and version[1] > "0"
self._cached_version_greater_than_4_0 = result
return result
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pyTigerGraph"
version = "2.0.3"
version = "2.0.4"
description = "Library to connect to TigerGraph databases"
readme = "README.md"
license = "Apache-2.0"
Expand Down