From 8f48e687999555c6aa4ba6f4f410345ae2df3b80 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 7 Oct 2026 15:17:52 -0700 Subject: [PATCH 1/5] fix(SOF-8067): send the auth context's current token on every request Since #47 an endpoint builds its headers once, at construction, from the AuthContext. A token replaced on the shared context after a re-login (client.auth.access_token = new) never reached client.jobs, client.materials or client.properties: their next request still carried the expired bearer token and failed with 401. BaseEndpoint.get_request_headers() returns the endpoint headers merged with the auth context's current headers, and request() uses it whenever the caller passes the endpoint's own headers. BaseEndpoint.auth exposes the auth context read-only. Together they give callers that send requests themselves, such as the browser fetch in api-examples, a public way to the current headers instead of private members. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../mat3ra/api_client/endpoints/__init__.py | 9 +++++++ tests/py/unit/test_auth_context.py | 26 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/py/mat3ra/api_client/endpoints/__init__.py b/src/py/mat3ra/api_client/endpoints/__init__.py index a568656..ed53b39 100644 --- a/src/py/mat3ra/api_client/endpoints/__init__.py +++ b/src/py/mat3ra/api_client/endpoints/__init__.py @@ -22,6 +22,10 @@ def __init__(self, host, port, version="2018-10-1", secure=True, **kwargs): self._auth = kwargs.get("auth") self.conn = Connection(host, port, version=version, secure=secure, **kwargs) + @property + def auth(self): + return self._auth + def request(self, method, endpoint_path, params=None, data=None, headers=None): """ Sends an HTTP request with given params, headers and data to the given endpoint. @@ -36,6 +40,8 @@ def request(self, method, endpoint_path, params=None, data=None, headers=None): Returns: json: response """ + if headers and headers is self.headers: + headers = self.get_request_headers() with self.conn: self.conn.request(method, endpoint_path, params, data, headers) response = self.conn.json() @@ -46,3 +52,6 @@ def request(self, method, endpoint_path, params=None, data=None, headers=None): def get_headers(self, account_id, auth_token, content_type="application/json"): auth = self._auth or AuthContext(account_id=account_id, auth_token=auth_token) return {**auth.get_headers(), "Content-Type": content_type} + + def get_request_headers(self): + return {**self.headers, **self._auth.get_headers()} if self._auth else self.headers diff --git a/tests/py/unit/test_auth_context.py b/tests/py/unit/test_auth_context.py index 669f278..cbf65a6 100644 --- a/tests/py/unit/test_auth_context.py +++ b/tests/py/unit/test_auth_context.py @@ -1,7 +1,10 @@ +from unittest import mock + import pytest -from mat3ra.api_client import AuthContext +from mat3ra.api_client import APIClient, AuthContext OIDC_ACCESS_TOKEN = "oidc-access-token" +OIDC_ACCESS_TOKEN_AFTER_LOGIN = "oidc-access-token-after-login" ACCOUNT_ID = "ubxMkAyx37Rjn8qK9" AUTH_TOKEN = "legacy-auth-token" @@ -11,6 +14,8 @@ BEARER_HEADERS = {"Authorization": f"Bearer {OIDC_ACCESS_TOKEN}"} API_TOKEN_HEADERS = {"X-Account-Id": ACCOUNT_ID, "X-Auth-Token": AUTH_TOKEN} +BEARER_HEADERS_AFTER_LOGIN = {"Authorization": f"Bearer {OIDC_ACCESS_TOKEN_AFTER_LOGIN}"} +CONTENT_TYPE_HEADERS = {"Content-Type": "application/json"} @pytest.mark.parametrize( @@ -23,3 +28,22 @@ ) def test_get_headers(auth, expected_headers): assert AuthContext(**auth).get_headers() == expected_headers + + +@pytest.mark.parametrize("endpoint_name", ["jobs", "materials", "properties"]) +@pytest.mark.parametrize( + "auth, access_token_after_login, expected_headers", + [ + (OIDC_AUTH, OIDC_ACCESS_TOKEN_AFTER_LOGIN, BEARER_HEADERS_AFTER_LOGIN), + (API_TOKEN_AUTH, None, API_TOKEN_HEADERS), + ], +) +def test_endpoint_request_sends_current_auth_headers(endpoint_name, auth, access_token_after_login, expected_headers): + client = APIClient(host="localhost", port=443, version="2018-10-01", secure=True, auth=AuthContext(**auth)) + endpoint = getattr(client, endpoint_name) + client.auth.access_token = access_token_after_login + with mock.patch("requests.sessions.Session.request") as request: + request.return_value.json.return_value = {"status": "success", "data": []} + endpoint.list() + assert endpoint.auth is client.auth + assert request.call_args[1]["headers"] == expected_headers | CONTENT_TYPE_HEADERS From f1f18b8c3537551d307f99f18baa68db77457f92 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 7 Oct 2026 15:29:34 -0700 Subject: [PATCH 2/5] fix(SOF-8067): merge the current credentials into any headers a request is given 8f48e68 refreshed the token only when the caller passed the endpoint's own headers object, and to check that it read self.headers. A plain BaseEndpoint has no headers attribute, so api-examples' upload_files, which builds a BaseEndpoint and passes headers from get_headers(), raised AttributeError. request() now passes any headers it is given through get_request_headers(), which merges in the auth context's current credentials. The endpoint's own headers are the default only when no headers are given. The first login request passes none and has no auth context, so it is left as is. Docstrings on auth and get_request_headers say why the token is read per request: a token replaced on the shared AuthContext after a re-login must reach every endpoint. The test now covers jobs.list() and the raw endpoint the api-examples files path uses, for both auth types. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../mat3ra/api_client/endpoints/__init__.py | 25 ++++++++++++++++--- tests/py/unit/test_auth_context.py | 22 ++++++++++++---- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/py/mat3ra/api_client/endpoints/__init__.py b/src/py/mat3ra/api_client/endpoints/__init__.py index ed53b39..bcd54b0 100644 --- a/src/py/mat3ra/api_client/endpoints/__init__.py +++ b/src/py/mat3ra/api_client/endpoints/__init__.py @@ -24,6 +24,12 @@ def __init__(self, host, port, version="2018-10-1", secure=True, **kwargs): @property def auth(self): + """ + Returns the auth context shared with the API client and its other endpoints. + + Returns: + AuthContext + """ return self._auth def request(self, method, endpoint_path, params=None, data=None, headers=None): @@ -40,8 +46,8 @@ def request(self, method, endpoint_path, params=None, data=None, headers=None): Returns: json: response """ - if headers and headers is self.headers: - headers = self.get_request_headers() + if headers: + headers = self.get_request_headers(headers) with self.conn: self.conn.request(method, endpoint_path, params, data, headers) response = self.conn.json() @@ -53,5 +59,16 @@ def get_headers(self, account_id, auth_token, content_type="application/json"): auth = self._auth or AuthContext(account_id=account_id, auth_token=auth_token) return {**auth.get_headers(), "Content-Type": content_type} - def get_request_headers(self): - return {**self.headers, **self._auth.get_headers()} if self._auth else self.headers + def get_request_headers(self, headers=None): + """ + Returns the given headers, or the endpoint's own, with the current credentials of the auth context. Read per + request, not at construction, so a token replaced on the shared AuthContext (a re-login) reaches every endpoint. + + Args: + headers (dict): headers to send. Defaults to the endpoint's `headers`. + + Returns: + dict + """ + headers = self.headers if headers is None else headers + return {**headers, **self._auth.get_headers()} if self._auth else headers diff --git a/tests/py/unit/test_auth_context.py b/tests/py/unit/test_auth_context.py index cbf65a6..fc91003 100644 --- a/tests/py/unit/test_auth_context.py +++ b/tests/py/unit/test_auth_context.py @@ -2,6 +2,7 @@ import pytest from mat3ra.api_client import APIClient, AuthContext +from mat3ra.api_client.endpoints import BaseEndpoint OIDC_ACCESS_TOKEN = "oidc-access-token" OIDC_ACCESS_TOKEN_AFTER_LOGIN = "oidc-access-token-after-login" @@ -30,7 +31,17 @@ def test_get_headers(auth, expected_headers): assert AuthContext(**auth).get_headers() == expected_headers -@pytest.mark.parametrize("endpoint_name", ["jobs", "materials", "properties"]) +def list_jobs(client): + return client.jobs.list + + +def post_files(client): + endpoint = BaseEndpoint("localhost", 443, auth=client.auth) + headers = endpoint.get_headers(ACCOUNT_ID, AUTH_TOKEN) + return lambda: endpoint.request("POST", "files", headers=headers) + + +@pytest.mark.parametrize("prepare_request", [list_jobs, post_files]) @pytest.mark.parametrize( "auth, access_token_after_login, expected_headers", [ @@ -38,12 +49,13 @@ def test_get_headers(auth, expected_headers): (API_TOKEN_AUTH, None, API_TOKEN_HEADERS), ], ) -def test_endpoint_request_sends_current_auth_headers(endpoint_name, auth, access_token_after_login, expected_headers): +def test_request_sends_current_auth_headers(prepare_request, auth, access_token_after_login, expected_headers): client = APIClient(host="localhost", port=443, version="2018-10-01", secure=True, auth=AuthContext(**auth)) - endpoint = getattr(client, endpoint_name) + send_request = prepare_request(client) client.auth.access_token = access_token_after_login with mock.patch("requests.sessions.Session.request") as request: request.return_value.json.return_value = {"status": "success", "data": []} - endpoint.list() - assert endpoint.auth is client.auth + send_request() + assert client.jobs.auth is client.auth + assert client.jobs.get_request_headers() == expected_headers | CONTENT_TYPE_HEADERS assert request.call_args[1]["headers"] == expected_headers | CONTENT_TYPE_HEADERS From 09976b296e7a5785d659d50bfdd55ba7a39ad816 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 7 Oct 2026 15:31:19 -0700 Subject: [PATCH 3/5] fix(SOF-8067): send the configured timeout with every endpoint request The connection stored the client's timeout_seconds on session.timeout, an attribute requests ignores, and called session.request without timeout=. Endpoint calls therefore had no timeout at all: a platform that stops answering froze any notebook cell forever, natively and in JupyterLite. The connection now passes the stored value as timeout= on each request, so APIClient's timeout_seconds (60 by default) bounds every endpoint call. The /users/me request already sent its own 30 s timeout. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/py/mat3ra/api_client/utils/http.py | 4 +++- tests/py/unit/test_client.py | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/py/mat3ra/api_client/utils/http.py b/src/py/mat3ra/api_client/utils/http.py index c91de1e..7ee9cc5 100644 --- a/src/py/mat3ra/api_client/utils/http.py +++ b/src/py/mat3ra/api_client/utils/http.py @@ -40,7 +40,9 @@ def request(self, method, url, params=None, data=None, headers=None): data (dict): the body to attach to the request. params (dict): URL parameters to append to the URL. """ - self.response = self.session.request(method=method.lower(), url=url, params=params, data=data, headers=headers) + self.response = self.session.request( + method=method.lower(), url=url, params=params, data=data, headers=headers, timeout=self.session.timeout + ) try: self.response.raise_for_status() except requests.HTTPError: diff --git a/tests/py/unit/test_client.py b/tests/py/unit/test_client.py index 2e6b65d..8bbe08d 100644 --- a/tests/py/unit/test_client.py +++ b/tests/py/unit/test_client.py @@ -1,7 +1,8 @@ import os from unittest import mock -from mat3ra.api_client import APIClient +import pytest +from mat3ra.api_client import APIClient, AuthContext from tests.py.unit import EndpointBaseUnitTest @@ -152,3 +153,15 @@ def test_my_organization(self, mock_get): org = client.my_organization self.assertEqual(org.id, "org-acc-1") self.assertEqual(org.name, "Acme Corp") + + +@pytest.mark.parametrize("timeout_seconds", [5, 120]) +def test_endpoint_request_sends_client_timeout(timeout_seconds): + auth = AuthContext(access_token=OIDC_ACCESS_TOKEN) + client = APIClient( + host=API_HOST, port=API_PORT, version=API_VERSION, secure=False, auth=auth, timeout_seconds=timeout_seconds + ) + with mock.patch("requests.sessions.Session.request") as request: + request.return_value.json.return_value = {"status": "success", "data": []} + client.jobs.list() + assert request.call_args[1]["timeout"] == timeout_seconds From 26bd924316b6f656f7683e6fa9ed46743a1d0824 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 7 Oct 2026 15:45:47 -0700 Subject: [PATCH 4/5] docs(SOF-8067): request() says the current auth context's headers are merged over the given ones Co-Authored-By: Claude Opus 5.5 (1M context) --- src/py/mat3ra/api_client/endpoints/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/mat3ra/api_client/endpoints/__init__.py b/src/py/mat3ra/api_client/endpoints/__init__.py index bcd54b0..8a13303 100644 --- a/src/py/mat3ra/api_client/endpoints/__init__.py +++ b/src/py/mat3ra/api_client/endpoints/__init__.py @@ -39,7 +39,7 @@ def request(self, method, endpoint_path, params=None, data=None, headers=None): Args: method (str): HTTP method to use. endpoint_path (str): endpoint path. - headers (dict): headers to send. + headers (dict): headers to send; the current auth context's headers are merged over them. data (dict): the body to attach to the request. params (dict): URL parameters to append to the URL. From c3f726330a58efa340e358ebe52b6c2823979fc1 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Wed, 7 Oct 2026 19:21:43 -0700 Subject: [PATCH 5/5] fix(SOF-8067): cut to the minimum the behaviour needs request() merges the endpoint's auth context headers into the given ones; the endpoint keeps the auth context as a plain public attribute. One test covers a token replaced after the client is built and the client timeout. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../mat3ra/api_client/endpoints/__init__.py | 34 +++-------------- tests/py/unit/test_auth_context.py | 38 +------------------ tests/py/unit/test_client.py | 22 +++++------ 3 files changed, 17 insertions(+), 77 deletions(-) diff --git a/src/py/mat3ra/api_client/endpoints/__init__.py b/src/py/mat3ra/api_client/endpoints/__init__.py index 8a13303..3316f9f 100644 --- a/src/py/mat3ra/api_client/endpoints/__init__.py +++ b/src/py/mat3ra/api_client/endpoints/__init__.py @@ -19,19 +19,9 @@ class BaseEndpoint(object): """ def __init__(self, host, port, version="2018-10-1", secure=True, **kwargs): - self._auth = kwargs.get("auth") + self.auth = kwargs.get("auth") self.conn = Connection(host, port, version=version, secure=secure, **kwargs) - @property - def auth(self): - """ - Returns the auth context shared with the API client and its other endpoints. - - Returns: - AuthContext - """ - return self._auth - def request(self, method, endpoint_path, params=None, data=None, headers=None): """ Sends an HTTP request with given params, headers and data to the given endpoint. @@ -39,15 +29,15 @@ def request(self, method, endpoint_path, params=None, data=None, headers=None): Args: method (str): HTTP method to use. endpoint_path (str): endpoint path. - headers (dict): headers to send; the current auth context's headers are merged over them. + headers (dict): headers to send. data (dict): the body to attach to the request. params (dict): URL parameters to append to the URL. Returns: json: response """ - if headers: - headers = self.get_request_headers(headers) + if headers and self.auth: + headers = {**headers, **self.auth.get_headers()} with self.conn: self.conn.request(method, endpoint_path, params, data, headers) response = self.conn.json() @@ -56,19 +46,5 @@ def request(self, method, endpoint_path, params=None, data=None, headers=None): return response["data"] def get_headers(self, account_id, auth_token, content_type="application/json"): - auth = self._auth or AuthContext(account_id=account_id, auth_token=auth_token) + auth = self.auth or AuthContext(account_id=account_id, auth_token=auth_token) return {**auth.get_headers(), "Content-Type": content_type} - - def get_request_headers(self, headers=None): - """ - Returns the given headers, or the endpoint's own, with the current credentials of the auth context. Read per - request, not at construction, so a token replaced on the shared AuthContext (a re-login) reaches every endpoint. - - Args: - headers (dict): headers to send. Defaults to the endpoint's `headers`. - - Returns: - dict - """ - headers = self.headers if headers is None else headers - return {**headers, **self._auth.get_headers()} if self._auth else headers diff --git a/tests/py/unit/test_auth_context.py b/tests/py/unit/test_auth_context.py index fc91003..669f278 100644 --- a/tests/py/unit/test_auth_context.py +++ b/tests/py/unit/test_auth_context.py @@ -1,11 +1,7 @@ -from unittest import mock - import pytest -from mat3ra.api_client import APIClient, AuthContext -from mat3ra.api_client.endpoints import BaseEndpoint +from mat3ra.api_client import AuthContext OIDC_ACCESS_TOKEN = "oidc-access-token" -OIDC_ACCESS_TOKEN_AFTER_LOGIN = "oidc-access-token-after-login" ACCOUNT_ID = "ubxMkAyx37Rjn8qK9" AUTH_TOKEN = "legacy-auth-token" @@ -15,8 +11,6 @@ BEARER_HEADERS = {"Authorization": f"Bearer {OIDC_ACCESS_TOKEN}"} API_TOKEN_HEADERS = {"X-Account-Id": ACCOUNT_ID, "X-Auth-Token": AUTH_TOKEN} -BEARER_HEADERS_AFTER_LOGIN = {"Authorization": f"Bearer {OIDC_ACCESS_TOKEN_AFTER_LOGIN}"} -CONTENT_TYPE_HEADERS = {"Content-Type": "application/json"} @pytest.mark.parametrize( @@ -29,33 +23,3 @@ ) def test_get_headers(auth, expected_headers): assert AuthContext(**auth).get_headers() == expected_headers - - -def list_jobs(client): - return client.jobs.list - - -def post_files(client): - endpoint = BaseEndpoint("localhost", 443, auth=client.auth) - headers = endpoint.get_headers(ACCOUNT_ID, AUTH_TOKEN) - return lambda: endpoint.request("POST", "files", headers=headers) - - -@pytest.mark.parametrize("prepare_request", [list_jobs, post_files]) -@pytest.mark.parametrize( - "auth, access_token_after_login, expected_headers", - [ - (OIDC_AUTH, OIDC_ACCESS_TOKEN_AFTER_LOGIN, BEARER_HEADERS_AFTER_LOGIN), - (API_TOKEN_AUTH, None, API_TOKEN_HEADERS), - ], -) -def test_request_sends_current_auth_headers(prepare_request, auth, access_token_after_login, expected_headers): - client = APIClient(host="localhost", port=443, version="2018-10-01", secure=True, auth=AuthContext(**auth)) - send_request = prepare_request(client) - client.auth.access_token = access_token_after_login - with mock.patch("requests.sessions.Session.request") as request: - request.return_value.json.return_value = {"status": "success", "data": []} - send_request() - assert client.jobs.auth is client.auth - assert client.jobs.get_request_headers() == expected_headers | CONTENT_TYPE_HEADERS - assert request.call_args[1]["headers"] == expected_headers | CONTENT_TYPE_HEADERS diff --git a/tests/py/unit/test_client.py b/tests/py/unit/test_client.py index 8bbe08d..cac678b 100644 --- a/tests/py/unit/test_client.py +++ b/tests/py/unit/test_client.py @@ -1,7 +1,6 @@ import os from unittest import mock -import pytest from mat3ra.api_client import APIClient, AuthContext from tests.py.unit import EndpointBaseUnitTest @@ -12,6 +11,7 @@ API_SECURE_FALSE = "false" OIDC_ACCESS_TOKEN = "oidc-access-token" +NEW_OIDC_ACCESS_TOKEN = "new-oidc-access-token" AUTH_TOKEN = "legacy-auth-token" ACCOUNT_ID = "ubxMkAyx37Rjn8qK9" @@ -154,14 +154,14 @@ def test_my_organization(self, mock_get): self.assertEqual(org.id, "org-acc-1") self.assertEqual(org.name, "Acme Corp") - -@pytest.mark.parametrize("timeout_seconds", [5, 120]) -def test_endpoint_request_sends_client_timeout(timeout_seconds): - auth = AuthContext(access_token=OIDC_ACCESS_TOKEN) - client = APIClient( - host=API_HOST, port=API_PORT, version=API_VERSION, secure=False, auth=auth, timeout_seconds=timeout_seconds - ) - with mock.patch("requests.sessions.Session.request") as request: - request.return_value.json.return_value = {"status": "success", "data": []} + @mock.patch("requests.sessions.Session.request") + def test_endpoint_request_sends_current_token_and_timeout(self, mock_request): + auth = AuthContext(access_token=OIDC_ACCESS_TOKEN) + client = APIClient( + host=API_HOST, port=API_PORT, version=API_VERSION, secure=False, auth=auth, timeout_seconds=5 + ) + client.auth.access_token = NEW_OIDC_ACCESS_TOKEN + mock_request.return_value.json.return_value = {"status": "success", "data": []} client.jobs.list() - assert request.call_args[1]["timeout"] == timeout_seconds + self.assertEqual(mock_request.call_args[1]["headers"]["Authorization"], f"Bearer {NEW_OIDC_ACCESS_TOKEN}") + self.assertEqual(mock_request.call_args[1]["timeout"], 5)