Skip to content

Commit fa69a60

Browse files
authored
Make Server thread-safe with per-thread sessions (#1871)
* fix: make Server thread-safe with per-thread sessions * fix torn auth-state reads exposed by free-threaded Python * restore Server._session as a backwards-compatible property shim
1 parent f76d9f3 commit fa69a60

2 files changed

Lines changed: 482 additions & 26 deletions

File tree

tableauserverclient/server/server.py

Lines changed: 190 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
from tableauserverclient.helpers.logging import logger
22

33
import requests
4+
import threading
45
import urllib3
56
import ssl
7+
import weakref
8+
9+
from typing import NamedTuple, Optional
610

711
from defusedxml.ElementTree import fromstring, ParseError
812
from packaging.version import Version
@@ -60,6 +64,23 @@
6064
default_server_version = "2.4" # first version that dropped the legacy auth endpoint
6165

6266

67+
class _AuthState(NamedTuple):
68+
"""
69+
Immutable bundle of the auth fields that must change together.
70+
71+
The whole state is swapped by assigning a new instance, and a single
72+
reference assignment or read is atomic on every CPython build,
73+
free-threaded (no-GIL) builds included. Readers take one snapshot of
74+
``Server._auth_state`` and destructure it, so they can never observe a
75+
token paired with the site or user of a different sign in.
76+
"""
77+
78+
site_id: Optional[str] = None
79+
user_id: Optional[str] = None
80+
auth_token: Optional[str] = None
81+
site_url: Optional[str] = None
82+
83+
6384
class Server:
6485
"""
6586
In the Tableau REST API, the server (https://MY-SERVER/) is the base or core
@@ -121,6 +142,18 @@ class Server:
121142
122143
Notes
123144
-----
145+
A single Server instance may be shared across threads (for example with
146+
``concurrent.futures.ThreadPoolExecutor``). Authentication state (the auth
147+
token, site and user IDs, and API version) is shared by all threads, while
148+
each thread transparently gets its own ``requests.Session`` for HTTP calls,
149+
since ``requests.Session`` itself is not guaranteed to be thread-safe. Sign
150+
in, and call ``use_server_version()`` if you need it, before spawning
151+
worker threads; signing out invalidates the sessions of all threads.
152+
``session_factory`` may be called once per thread, so a custom factory
153+
should be safe to call concurrently. Call ``close()`` (or use the Server
154+
as a context manager) after worker threads finish to release the pooled
155+
HTTP connections of every thread's session.
156+
124157
When using Python 3.12 or later with older versions of Tableau Server, you may encounter
125158
SSL errors related to weak Diffie-Hellman keys. This is because newer Python versions
126159
enforce stronger security requirements. You can temporarily work around this using
@@ -141,9 +174,7 @@ class PublishMode:
141174
Replace = "Replace"
142175

143176
def __init__(self, server_address, use_server_version=False, http_options=None, session_factory=None):
144-
self._auth_token = None
145-
self._site_id = None
146-
self._user_id = None
177+
self._auth_state = _AuthState()
147178
self._ssl_context = None
148179

149180
# TODO: this needs to change to default to https, but without breaking existing code
@@ -153,6 +184,22 @@ def __init__(self, server_address, use_server_version=False, http_options=None,
153184
self._server_address: str = server_address
154185
self._session_factory = session_factory or requests.session
155186

187+
# Thread-safety machinery. requests.Session is not guaranteed to be
188+
# thread-safe (see psf/requests#2766), so each thread that makes calls
189+
# through this Server instance gets its own Session object, created
190+
# lazily from session_factory. The epoch counter invalidates every
191+
# thread's cached session when auth state is cleared (sign out). The
192+
# lock guards the epoch counter and the session WeakSet; auth state
193+
# itself is an immutable _AuthState swapped by reference and needs
194+
# no lock.
195+
self._session_lock = threading.Lock()
196+
self._session_epoch = 0
197+
self._thread_sessions = threading.local()
198+
# Weak references to every session created for any thread, so close()
199+
# can release their pooled connections. Weak so that sessions belonging
200+
# to threads that have exited can still be garbage collected.
201+
self._all_sessions: "weakref.WeakSet[requests.Session]" = weakref.WeakSet()
202+
156203
self.auth = Auth(self)
157204
self.views = Views(self)
158205
self.users = Users(self)
@@ -187,7 +234,6 @@ def __init__(self, server_address, use_server_version=False, http_options=None,
187234
self.oidc = OIDC(self)
188235
self.extensions = Extensions(self)
189236

190-
self._session = self._session_factory()
191237
self._http_options = dict() # must set this before making a server call
192238
if http_options:
193239
self.add_http_options(http_options)
@@ -203,7 +249,7 @@ def validate_connection_settings(self):
203249
Endpoint.set_user_agent(params)
204250
if not self._server_address.startswith("http://") and not self._server_address.startswith("https://"):
205251
self._server_address = "http://" + self._server_address
206-
self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options))
252+
self.session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options))
207253
except Exception as req_ex:
208254
raise ValueError("Server connection settings not valid", req_ex)
209255

@@ -226,21 +272,64 @@ def clear_http_options(self):
226272
self._http_options = dict()
227273

228274
def _clear_auth(self):
229-
self._site_id = None
230-
self._user_id = None
231-
self._auth_token = None
232-
self._site_url = None
233-
self._session = self._session_factory()
275+
# swapping the whole immutable state in one assignment is atomic on
276+
# every CPython build, free-threaded (no-GIL) builds included
277+
self._auth_state = _AuthState()
278+
with self._session_lock:
279+
# Invalidate the cached session of every thread so state such as
280+
# cookies does not leak into a later sign in. Sessions are replaced
281+
# lazily on next use rather than closed here, so requests already
282+
# in flight on other threads are not disrupted (this matches the
283+
# previous behavior of re-assigning the shared session). The lock
284+
# guards the epoch increment, which is not atomic without the GIL.
285+
self._session_epoch += 1
234286

235287
def _set_auth(self, site_id, user_id, auth_token, site_url=None):
236-
self._site_id = site_id
237-
self._user_id = user_id
238-
self._auth_token = auth_token
239-
self._site_url = site_url
288+
# swapping the whole immutable state in one assignment is atomic on
289+
# every CPython build, free-threaded (no-GIL) builds included, so
290+
# readers can never observe a partially-updated state
291+
self._auth_state = _AuthState(site_id, user_id, auth_token, site_url)
292+
293+
# Backwards-compatible access to the individual auth fields. Existing code
294+
# (including this project's tests) reads and assigns these directly as a
295+
# sign-in shortcut. Each assignment swaps in a fully-formed state, so
296+
# readers never see torn fields; assigning fields one at a time is not
297+
# atomic as a group, though, so concurrent writers should use _set_auth.
298+
@property
299+
def _auth_token(self):
300+
return self._auth_state.auth_token
301+
302+
@_auth_token.setter
303+
def _auth_token(self, value) -> None:
304+
self._auth_state = self._auth_state._replace(auth_token=value)
305+
306+
@property
307+
def _site_id(self):
308+
return self._auth_state.site_id
309+
310+
@_site_id.setter
311+
def _site_id(self, value) -> None:
312+
self._auth_state = self._auth_state._replace(site_id=value)
313+
314+
@property
315+
def _user_id(self):
316+
return self._auth_state.user_id
317+
318+
@_user_id.setter
319+
def _user_id(self, value) -> None:
320+
self._auth_state = self._auth_state._replace(user_id=value)
321+
322+
@property
323+
def _site_url(self):
324+
return self._auth_state.site_url
325+
326+
@_site_url.setter
327+
def _site_url(self, value) -> None:
328+
self._auth_state = self._auth_state._replace(site_url=value)
240329

241330
def _get_legacy_version(self):
242331
# the serverInfo call was introduced in 2.4, earlier than that we have this different call
243-
response = self._session.get(self.server_address + "/auth?format=xml")
332+
response = self.session.get(self.server_address + "/auth?format=xml")
244333
try:
245334
info_xml = fromstring(response.content)
246335
except ParseError as parseError:
@@ -294,31 +383,37 @@ def namespace(self):
294383

295384
@property
296385
def auth_token(self):
297-
if self._auth_token is None:
386+
# read the state once: a second self._auth_state read could observe
387+
# a concurrent sign out and return None instead of raising
388+
token = self._auth_state.auth_token
389+
if token is None:
298390
error = "Missing authentication token. You must sign in first."
299391
raise NotSignedInError(error)
300-
return self._auth_token
392+
return token
301393

302394
@property
303395
def site_id(self):
304-
if self._site_id is None:
396+
site_id = self._auth_state.site_id
397+
if site_id is None:
305398
error = "Missing site ID. You must sign in first."
306399
raise NotSignedInError(error)
307-
return self._site_id
400+
return site_id
308401

309402
@property
310403
def site_url(self):
311-
if self._site_url is None:
404+
site_url = self._auth_state.site_url
405+
if site_url is None:
312406
error = "Missing site URL. You must sign in first."
313407
raise NotSignedInError(error)
314-
return self._site_url
408+
return site_url
315409

316410
@property
317411
def user_id(self):
318-
if self._user_id is None:
412+
user_id = self._auth_state.user_id
413+
if user_id is None:
319414
error = "Missing user ID. You must sign in first."
320415
raise NotSignedInError(error)
321-
return self._user_id
416+
return user_id
322417

323418
@property
324419
def server_address(self):
@@ -329,11 +424,50 @@ def http_options(self):
329424
return self._http_options
330425

331426
@property
332-
def session(self):
333-
return self._session
427+
def session(self) -> requests.Session:
428+
"""
429+
The requests.Session used for HTTP calls made by the current thread.
430+
431+
requests.Session is not guaranteed to be thread-safe (see
432+
psf/requests#2766), so each thread that makes calls through this Server
433+
instance transparently gets its own Session object, created from
434+
``session_factory``. Sessions are cached per thread, so a thread pool
435+
worker reuses its session (and its connection pool) across tasks.
436+
Signing out invalidates the cached sessions of all threads.
437+
"""
438+
local = self._thread_sessions
439+
epoch = self._session_epoch
440+
if getattr(local, "session", None) is None or local.epoch != epoch:
441+
session = self._session_factory()
442+
with self._session_lock:
443+
self._all_sessions.add(session)
444+
local.session = session
445+
# `epoch` was read before the factory ran: if a sign out happened
446+
# in between, local.epoch is already stale and the session will be
447+
# replaced on the next access.
448+
local.epoch = epoch
449+
return local.session
450+
451+
# Backwards-compatible access to the pre-thread-safety private attribute.
452+
# Reading returns the current thread's session; assigning replaces the
453+
# CURRENT thread's session only (other threads keep sessions created by
454+
# session_factory), which preserves the common single-threaded pattern of
455+
# injecting a prepared session before making calls. The injected session
456+
# is registered so close() still reaches it.
457+
@property
458+
def _session(self) -> requests.Session:
459+
return self.session
460+
461+
@_session.setter
462+
def _session(self, value: requests.Session) -> None:
463+
local = self._thread_sessions
464+
with self._session_lock:
465+
self._all_sessions.add(value)
466+
local.session = value
467+
local.epoch = self._session_epoch
334468

335469
def is_signed_in(self):
336-
return self._auth_token is not None
470+
return self._auth_state.auth_token is not None
337471

338472
def configure_ssl(self, *, allow_weak_dh=False):
339473
"""Configure SSL/TLS settings for the server connection.
@@ -357,3 +491,33 @@ def configure_ssl(self, *, allow_weak_dh=False):
357491
# Remove any custom SSL context if we're reverting to default settings
358492
if "verify" in self._http_options:
359493
del self._http_options["verify"]
494+
495+
def close(self) -> None:
496+
"""
497+
Release the pooled HTTP connections held by every thread's session.
498+
499+
Call this when you are done with the server, after any worker threads
500+
have finished their requests. Closing is a transport-level operation:
501+
it does not sign out, so the auth token remains valid on the server
502+
(use ``auth.sign_out()`` for that). The Server object remains usable
503+
after close; any subsequent call transparently creates a new session.
504+
"""
505+
with self._session_lock:
506+
sessions = list(self._all_sessions)
507+
self._all_sessions.clear()
508+
# Invalidate every thread's cached (now closed) session so later
509+
# use creates a fresh one instead of hitting closed pools.
510+
self._session_epoch += 1
511+
for session in sessions:
512+
session.close()
513+
514+
def __enter__(self) -> "Server":
515+
return self
516+
517+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
518+
# Note: does not sign out. server.auth.sign_in() already returns a
519+
# context manager that signs out; nesting the two composes cleanly:
520+
# with TSC.Server(...) as server:
521+
# with server.auth.sign_in(auth):
522+
# ...
523+
self.close()

0 commit comments

Comments
 (0)