From 308adc1f403a326bef6882a3b6853482bc84f1af Mon Sep 17 00:00:00 2001 From: Katie West Date: Fri, 25 Sep 2026 09:24:43 -0700 Subject: [PATCH 1/2] feat(directory-sync): Add on-demand sync (FEAT-3084) --- .oagen-manifest.json | 8 ++ src/workos/common/models/__init__.py | 3 + .../models/directory_sync_rate_limit_error.py | 40 ++++++++++ src/workos/directory_sync/_resource.py | 68 +++++++++++++++- src/workos/directory_sync/models/__init__.py | 1 + .../models/directory_sync_response.py | 32 ++++++++ .../directory_sync_rate_limit_error.json | 5 ++ tests/fixtures/directory_sync_response.json | 3 + tests/test_common_models_round_trip.py | 21 +++++ tests/test_directory_sync.py | 27 ++++++- tests/test_directory_sync_manual_contract.py | 80 +++++++++++++++++++ .../test_directory_sync_models_round_trip.py | 15 ++++ 12 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 src/workos/common/models/directory_sync_rate_limit_error.py create mode 100644 src/workos/directory_sync/models/directory_sync_response.py create mode 100644 tests/fixtures/directory_sync_rate_limit_error.json create mode 100644 tests/fixtures/directory_sync_response.json create mode 100644 tests/test_directory_sync_manual_contract.py diff --git a/.oagen-manifest.json b/.oagen-manifest.json index f04e001b..b4514d11 100644 --- a/.oagen-manifest.json +++ b/.oagen-manifest.json @@ -316,6 +316,7 @@ "src/workos/common/models/data_integrations_vend_credentials_request_connection_owner.py", "src/workos/common/models/directory_group.py", "src/workos/common/models/directory_state.py", + "src/workos/common/models/directory_sync_rate_limit_error.py", "src/workos/common/models/directory_type.py", "src/workos/common/models/directory_user.py", "src/workos/common/models/directory_user_email.py", @@ -637,6 +638,7 @@ "src/workos/directory_sync/models/directory.py", "src/workos/directory_sync/models/directory_metadata.py", "src/workos/directory_sync/models/directory_metadata_user.py", + "src/workos/directory_sync/models/directory_sync_response.py", "src/workos/directory_sync/models/directory_user_with_groups.py", "src/workos/directory_sync/models/directory_user_with_groups_email.py", "src/workos/events/__init__.py", @@ -1167,6 +1169,8 @@ "tests/fixtures/directory_group.json", "tests/fixtures/directory_metadata.json", "tests/fixtures/directory_metadata_user.json", + "tests/fixtures/directory_sync_rate_limit_error.json", + "tests/fixtures/directory_sync_response.json", "tests/fixtures/directory_user.json", "tests/fixtures/directory_user_email.json", "tests/fixtures/directory_user_with_groups.json", @@ -2633,6 +2637,10 @@ "GET /audit_logs/exports/{auditLogExportId}": { "sdkMethod": "get_export", "service": "audit_logs" + }, + "POST /directories/{id}/sync": { + "sdkMethod": "sync_directory", + "service": "directory_sync" } } } diff --git a/src/workos/common/models/__init__.py b/src/workos/common/models/__init__.py index 9e76b7e4..37475743 100644 --- a/src/workos/common/models/__init__.py +++ b/src/workos/common/models/__init__.py @@ -526,6 +526,9 @@ ) from .directory_group import DirectoryGroup as DirectoryGroup from .directory_state import DirectoryState as DirectoryState +from .directory_sync_rate_limit_error import ( + DirectorySyncRateLimitError as DirectorySyncRateLimitError, +) from .directory_type import DirectoryType as DirectoryType from .directory_user import DirectoryUser as DirectoryUser from .directory_user_email import DirectoryUserEmail as DirectoryUserEmail diff --git a/src/workos/common/models/directory_sync_rate_limit_error.py b/src/workos/common/models/directory_sync_rate_limit_error.py new file mode 100644 index 00000000..1b610551 --- /dev/null +++ b/src/workos/common/models/directory_sync_rate_limit_error.py @@ -0,0 +1,40 @@ +# This file is auto-generated by oagen. Do not edit. + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +from workos._types import _raise_deserialize_error + + +@dataclass(slots=True) +class DirectorySyncRateLimitError: + """Directory Sync Rate Limit Error model.""" + + code: Literal["directory_sync_rate_limited"] + """The error code identifying the type of error.""" + message: str + """A human-readable description of the error.""" + retry_after_seconds: int + """The number of seconds to wait before requesting another manual sync of this directory.""" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> DirectorySyncRateLimitError: + """Deserialize from a dictionary.""" + try: + return cls( + code=data.get("code", "directory_sync_rate_limited"), + message=data["message"], + retry_after_seconds=data["retry_after_seconds"], + ) + except (KeyError, ValueError) as e: + _raise_deserialize_error("DirectorySyncRateLimitError", e) + + def to_dict(self) -> dict[str, Any]: + """Serialize to a dictionary.""" + result: dict[str, Any] = {} + result["code"] = self.code + result["message"] = self.message + result["retry_after_seconds"] = self.retry_after_seconds + return result diff --git a/src/workos/directory_sync/_resource.py b/src/workos/directory_sync/_resource.py index 0a7bdd45..ee3542ba 100644 --- a/src/workos/directory_sync/_resource.py +++ b/src/workos/directory_sync/_resource.py @@ -12,7 +12,7 @@ from .._pagination import AsyncPage, SyncPage from .._types import RequestOptions, enum_value -from .models import Directory, DirectoryUserWithGroups +from .models import Directory, DirectorySyncResponse, DirectoryUserWithGroups class DirectorySync: @@ -135,6 +135,39 @@ def delete_directory( request_options=request_options, ) + def sync_directory( + self, + id: str, + *, + request_options: RequestOptions | None = None, + ) -> DirectorySyncResponse: + """Sync a Directory + + Request an asynchronous sync from the directory provider. Currently supports Google Workspace directories in linked or validating state. Manual requests share a five-minute per-directory cooldown across the API, Dashboard, Admin Portal, and MCP. Acceptance means the request was queued, not that the sync has started or completed. A running sync prevents another request from being queued. + + Args: + id: Unique identifier for the Directory. + request_options: Per-request options. Supports extra_headers, timeout, max_retries, and base_url override. + + Returns: + DirectorySyncResponse + + Raises: + AuthorizationError: If the request is forbidden (403). + NotFoundError: If the resource is not found (404). + ConflictError: If a conflict occurs (409). + UnprocessableEntityError: If the request data is unprocessable (422). + RateLimitExceededError: If rate limited (429). + AuthenticationError: If the API key is invalid (401). + ServerError: If the server returns a 5xx error. + """ + return self._client.request( + method="post", + path=("directories", str(id), "sync"), + model=DirectorySyncResponse, + request_options=request_options, + ) + def list_groups( self, *, @@ -434,6 +467,39 @@ async def delete_directory( request_options=request_options, ) + async def sync_directory( + self, + id: str, + *, + request_options: RequestOptions | None = None, + ) -> DirectorySyncResponse: + """Sync a Directory + + Request an asynchronous sync from the directory provider. Currently supports Google Workspace directories in linked or validating state. Manual requests share a five-minute per-directory cooldown across the API, Dashboard, Admin Portal, and MCP. Acceptance means the request was queued, not that the sync has started or completed. A running sync prevents another request from being queued. + + Args: + id: Unique identifier for the Directory. + request_options: Per-request options. Supports extra_headers, timeout, max_retries, and base_url override. + + Returns: + DirectorySyncResponse + + Raises: + AuthorizationError: If the request is forbidden (403). + NotFoundError: If the resource is not found (404). + ConflictError: If a conflict occurs (409). + UnprocessableEntityError: If the request data is unprocessable (422). + RateLimitExceededError: If rate limited (429). + AuthenticationError: If the API key is invalid (401). + ServerError: If the server returns a 5xx error. + """ + return await self._client.request( + method="post", + path=("directories", str(id), "sync"), + model=DirectorySyncResponse, + request_options=request_options, + ) + async def list_groups( self, *, diff --git a/src/workos/directory_sync/models/__init__.py b/src/workos/directory_sync/models/__init__.py index f315d1b1..ecee47f3 100644 --- a/src/workos/directory_sync/models/__init__.py +++ b/src/workos/directory_sync/models/__init__.py @@ -5,6 +5,7 @@ from .directory import Directory as Directory from .directory_metadata import DirectoryMetadata as DirectoryMetadata from .directory_metadata_user import DirectoryMetadataUser as DirectoryMetadataUser +from .directory_sync_response import DirectorySyncResponse as DirectorySyncResponse from .directory_user_with_groups import ( DirectoryUserWithGroups as DirectoryUserWithGroups, ) diff --git a/src/workos/directory_sync/models/directory_sync_response.py b/src/workos/directory_sync/models/directory_sync_response.py new file mode 100644 index 00000000..1d3e3f6c --- /dev/null +++ b/src/workos/directory_sync/models/directory_sync_response.py @@ -0,0 +1,32 @@ +# This file is auto-generated by oagen. Do not edit. + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +from workos._types import _raise_deserialize_error + + +@dataclass(slots=True) +class DirectorySyncResponse: + """Directory Sync Response model.""" + + status: Literal["queued"] + """The sync request was queued for asynchronous processing. This does not indicate that the sync has started or completed.""" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> DirectorySyncResponse: + """Deserialize from a dictionary.""" + try: + return cls( + status=data.get("status", "queued"), + ) + except (KeyError, ValueError) as e: + _raise_deserialize_error("DirectorySyncResponse", e) + + def to_dict(self) -> dict[str, Any]: + """Serialize to a dictionary.""" + result: dict[str, Any] = {} + result["status"] = self.status + return result diff --git a/tests/fixtures/directory_sync_rate_limit_error.json b/tests/fixtures/directory_sync_rate_limit_error.json new file mode 100644 index 00000000..4e136b93 --- /dev/null +++ b/tests/fixtures/directory_sync_rate_limit_error.json @@ -0,0 +1,5 @@ +{ + "code": "directory_sync_rate_limited", + "message": "Request could not be processed.", + "retry_after_seconds": 120 +} diff --git a/tests/fixtures/directory_sync_response.json b/tests/fixtures/directory_sync_response.json new file mode 100644 index 00000000..ef041155 --- /dev/null +++ b/tests/fixtures/directory_sync_response.json @@ -0,0 +1,3 @@ +{ + "status": "queued" +} diff --git a/tests/test_common_models_round_trip.py b/tests/test_common_models_round_trip.py index 21438f6b..341f0765 100644 --- a/tests/test_common_models_round_trip.py +++ b/tests/test_common_models_round_trip.py @@ -127,6 +127,7 @@ ConnectionSAMLCertificateRenewedData, ConnectionSAMLCertificateRenewedDataCertificate, ConnectionSAMLCertificateRenewedDataConnection, + DirectorySyncRateLimitError, DirectoryUser, DirectoryUserEmail, DsyncActivated, @@ -479,6 +480,26 @@ def test_connect_application_m2m_preserves_nullable_fields(self): serialized = instance.to_dict() assert serialized["description"] is None + def test_directory_sync_rate_limit_error_round_trip(self): + data = load_fixture("directory_sync_rate_limit_error.json") + instance = DirectorySyncRateLimitError.from_dict(data) + serialized = instance.to_dict() + assert serialized == data + restored = DirectorySyncRateLimitError.from_dict(serialized) + assert restored.to_dict() == serialized + + def test_directory_sync_rate_limit_error_minimal_payload(self): + data = { + "code": "directory_sync_rate_limited", + "message": "Request could not be processed.", + "retry_after_seconds": 120, + } + instance = DirectorySyncRateLimitError.from_dict(data) + serialized = instance.to_dict() + assert serialized["code"] == data["code"] + assert serialized["message"] == data["message"] + assert serialized["retry_after_seconds"] == data["retry_after_seconds"] + def test_event_context_actor_round_trip(self): data = load_fixture("event_context_actor.json") instance = EventContextActor.from_dict(data) diff --git a/tests/test_directory_sync.py b/tests/test_directory_sync.py index ba71e0d3..10cd546f 100644 --- a/tests/test_directory_sync.py +++ b/tests/test_directory_sync.py @@ -15,7 +15,11 @@ ) from workos._pagination import AsyncPage, SyncPage from workos.common.models import DirectoryGroup, PaginationOrder -from workos.directory_sync.models import Directory, DirectoryUserWithGroups +from workos.directory_sync.models import ( + Directory, + DirectorySyncResponse, + DirectoryUserWithGroups, +) class TestDirectorySync: @@ -74,6 +78,17 @@ def test_delete_directory(self, workos, httpx_mock): assert request.method == "DELETE" assert request.url.path.endswith("/directories/test_id") + def test_sync_directory(self, workos, httpx_mock): + httpx_mock.add_response( + json=load_fixture("directory_sync_response.json"), + ) + result = workos.directory_sync.sync_directory("test_id") + assert isinstance(result, DirectorySyncResponse) + assert result.status == "queued" + request = httpx_mock.get_request() + assert request.method == "POST" + assert request.url.path.endswith("/directories/test_id/sync") + def test_list_groups(self, workos, httpx_mock): httpx_mock.add_response( json=load_fixture("list_directory_group.json"), @@ -303,6 +318,16 @@ async def test_delete_directory(self, async_workos, httpx_mock): assert request.method == "DELETE" assert request.url.path.endswith("/directories/test_id") + @pytest.mark.asyncio + async def test_sync_directory(self, async_workos, httpx_mock): + httpx_mock.add_response(json=load_fixture("directory_sync_response.json")) + result = await async_workos.directory_sync.sync_directory("test_id") + assert isinstance(result, DirectorySyncResponse) + assert result.status == "queued" + request = httpx_mock.get_request() + assert request.method == "POST" + assert request.url.path.endswith("/directories/test_id/sync") + @pytest.mark.asyncio async def test_list_groups(self, async_workos, httpx_mock): httpx_mock.add_response(json=load_fixture("list_directory_group.json")) diff --git a/tests/test_directory_sync_manual_contract.py b/tests/test_directory_sync_manual_contract.py new file mode 100644 index 00000000..afe4abaf --- /dev/null +++ b/tests/test_directory_sync_manual_contract.py @@ -0,0 +1,80 @@ +# @oagen-ignore-file + +import pytest + +from workos import ( + ConflictError, + RateLimitExceededError, + ServerError, + UnprocessableEntityError, +) +from workos.directory_sync.models import DirectorySyncResponse + + +def test_manual_sync_accepts_202(workos, httpx_mock): + httpx_mock.add_response(status_code=202, json={"status": "queued"}) + + result = workos.directory_sync.sync_directory("directory_123") + + assert isinstance(result, DirectorySyncResponse) + assert result.status == "queued" + sent = httpx_mock.get_request() + assert sent.method == "POST" + assert sent.url.path == "/directories/directory_123/sync" + assert sent.content == b"" + + +@pytest.mark.asyncio +async def test_async_manual_sync_accepts_202(async_workos, httpx_mock): + httpx_mock.add_response(status_code=202, json={"status": "queued"}) + + result = await async_workos.directory_sync.sync_directory("directory_123") + + assert isinstance(result, DirectorySyncResponse) + assert result.status == "queued" + assert httpx_mock.get_request().url.path == "/directories/directory_123/sync" + + +def test_manual_sync_preserves_rate_limit_details(workos, httpx_mock): + body = { + "code": "directory_sync_rate_limited", + "message": "Wait before requesting another sync.", + "retry_after_seconds": 120, + } + httpx_mock.add_response(status_code=429, json=body, headers={"Retry-After": "120"}) + + with pytest.raises(RateLimitExceededError) as raised: + workos.directory_sync.sync_directory( + "directory_123", request_options={"max_retries": 0} + ) + + assert raised.value.status_code == 429 + assert raised.value.code == "directory_sync_rate_limited" + assert raised.value.response_json["retry_after_seconds"] == 120 + assert raised.value.response.headers["Retry-After"] == "120" + assert len(httpx_mock.get_requests()) == 1 + + +@pytest.mark.parametrize( + ("status", "code", "error"), + [ + (409, "directory_sync_in_progress", ConflictError), + (422, "directory_sync_unsupported", UnprocessableEntityError), + (503, "directory_sync_disabled", ServerError), + ], +) +def test_manual_sync_does_not_report_errors_as_queued( + workos, httpx_mock, status, code, error +): + httpx_mock.add_response( + status_code=status, json={"code": code, "message": "Not queued."} + ) + + with pytest.raises(error) as raised: + workos.directory_sync.sync_directory( + "directory_123", request_options={"max_retries": 0} + ) + + assert raised.value.status_code == status + assert raised.value.code == code + assert len(httpx_mock.get_requests()) == 1 diff --git a/tests/test_directory_sync_models_round_trip.py b/tests/test_directory_sync_models_round_trip.py index dc7215e2..3864abaf 100644 --- a/tests/test_directory_sync_models_round_trip.py +++ b/tests/test_directory_sync_models_round_trip.py @@ -8,6 +8,7 @@ DirectoryGroup, DirectoryMetadata, DirectoryMetadataUser, + DirectorySyncResponse, DirectoryUserWithGroups, DirectoryUserWithGroupsEmail, ) @@ -80,6 +81,20 @@ def test_directory_round_trips_unknown_enum_values(self): instance = Directory.from_dict(data) assert instance.to_dict() == data + def test_directory_sync_response_round_trip(self): + data = load_fixture("directory_sync_response.json") + instance = DirectorySyncResponse.from_dict(data) + serialized = instance.to_dict() + assert serialized == data + restored = DirectorySyncResponse.from_dict(serialized) + assert restored.to_dict() == serialized + + def test_directory_sync_response_minimal_payload(self): + data = {"status": "queued"} + instance = DirectorySyncResponse.from_dict(data) + serialized = instance.to_dict() + assert serialized["status"] == data["status"] + def test_directory_group_round_trip(self): data = load_fixture("directory_group.json") instance = DirectoryGroup.from_dict(data) From 75df4fb3ffaa9450eebce22c975b60aecb5aada3 Mon Sep 17 00:00:00 2001 From: Katie West Date: Fri, 25 Sep 2026 09:27:16 -0700 Subject: [PATCH 2/2] test(directory-sync): Narrow optional error fields (FEAT-3084) --- tests/test_directory_sync_manual_contract.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_directory_sync_manual_contract.py b/tests/test_directory_sync_manual_contract.py index afe4abaf..75baba11 100644 --- a/tests/test_directory_sync_manual_contract.py +++ b/tests/test_directory_sync_manual_contract.py @@ -50,7 +50,9 @@ def test_manual_sync_preserves_rate_limit_details(workos, httpx_mock): assert raised.value.status_code == 429 assert raised.value.code == "directory_sync_rate_limited" + assert raised.value.response_json is not None assert raised.value.response_json["retry_after_seconds"] == 120 + assert raised.value.response is not None assert raised.value.response.headers["Retry-After"] == "120" assert len(httpx_mock.get_requests()) == 1