From 035b423d54d64e50bcffe7ddb26e5efd19164bfd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:54:33 +0000 Subject: [PATCH 1/2] Add email_verification_id field to BaseRequestException Fixes #309 When authenticate_with_password() raises an AuthorizationException for an unverified email, the API response includes an email_verification_id field. This field is now properly extracted and accessible on the exception object, eliminating the need to manually parse the response JSON. Co-Authored-By: Deep Singhvi --- tests/test_sync_http_client.py | 27 +++++++++++++++++++++++++++ workos/exceptions.py | 3 +++ 2 files changed, 30 insertions(+) diff --git a/tests/test_sync_http_client.py b/tests/test_sync_http_client.py index 2a0f571b..afea051c 100644 --- a/tests/test_sync_http_client.py +++ b/tests/test_sync_http_client.py @@ -263,6 +263,33 @@ def test_conflict_exception(self): assert str(ex) == "(message=No message, request_id=request-123)" assert ex.__class__ == ConflictException + def test_authorization_exception_includes_email_verification_id(self): + request_id = "request-123" + email_verification_id = "email_verification_01J6K4PMSWQXVFGF5ZQJXC6VC8" + + self.http_client._client.request = MagicMock( + return_value=httpx.Response( + status_code=403, + json={ + "message": "Please verify your email to authenticate via password.", + "code": "email_verification_required", + "email_verification_id": email_verification_id, + }, + headers={"X-Request-ID": request_id}, + ), + ) + + try: + self.http_client.request("bad_place") + except AuthorizationException as ex: + assert ( + ex.message == "Please verify your email to authenticate via password." + ) + assert ex.code == "email_verification_required" + assert ex.email_verification_id == email_verification_id + assert ex.request_id == request_id + assert ex.__class__ == AuthorizationException + def test_request_includes_base_headers(self, capture_and_mock_http_client_request): request_kwargs = capture_and_mock_http_client_request(self.http_client, {}, 200) diff --git a/workos/exceptions.py b/workos/exceptions.py index 9aee53b2..7ec4e90c 100644 --- a/workos/exceptions.py +++ b/workos/exceptions.py @@ -20,6 +20,9 @@ def __init__( self.errors = self.extract_from_json("errors", None) self.code = self.extract_from_json("code", None) self.error_description = self.extract_from_json("error_description", "Unknown") + self.email_verification_id = self.extract_from_json( + "email_verification_id", None + ) self.request_id = response.headers.get("X-Request-ID") From fed9dfc9cb3cedfe90c46958e501eed9a6906814 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:16:10 +0000 Subject: [PATCH 2/2] Replace base exception field with specific EmailVerificationRequiredException - Created EmailVerificationRequiredException subclass of AuthorizationException - Added email_verification_id field specific to this exception type - Updated HTTP client to detect email_verification_required error code and raise specific exception - Added tests for both the new exception and to verify regular AuthorizationException still works - All 370 tests pass Co-Authored-By: Deep Singhvi --- tests/test_sync_http_client.py | 29 +++++++++++++++++++++++++++-- workos/exceptions.py | 21 ++++++++++++++++++--- workos/utils/_base_http_client.py | 6 ++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/tests/test_sync_http_client.py b/tests/test_sync_http_client.py index afea051c..d86c5b99 100644 --- a/tests/test_sync_http_client.py +++ b/tests/test_sync_http_client.py @@ -10,6 +10,7 @@ BadRequestException, BaseRequestException, ConflictException, + EmailVerificationRequiredException, ServerException, ) from workos.utils.http_client import SyncHTTPClient @@ -263,7 +264,7 @@ def test_conflict_exception(self): assert str(ex) == "(message=No message, request_id=request-123)" assert ex.__class__ == ConflictException - def test_authorization_exception_includes_email_verification_id(self): + def test_email_verification_required_exception(self): request_id = "request-123" email_verification_id = "email_verification_01J6K4PMSWQXVFGF5ZQJXC6VC8" @@ -281,14 +282,38 @@ def test_authorization_exception_includes_email_verification_id(self): try: self.http_client.request("bad_place") - except AuthorizationException as ex: + except EmailVerificationRequiredException as ex: assert ( ex.message == "Please verify your email to authenticate via password." ) assert ex.code == "email_verification_required" assert ex.email_verification_id == email_verification_id assert ex.request_id == request_id + assert ex.__class__ == EmailVerificationRequiredException + assert isinstance(ex, AuthorizationException) + + def test_regular_authorization_exception_still_raised(self): + request_id = "request-123" + + self.http_client._client.request = MagicMock( + return_value=httpx.Response( + status_code=403, + json={ + "message": "You do not have permission to access this resource.", + "code": "forbidden", + }, + headers={"X-Request-ID": request_id}, + ), + ) + + try: + self.http_client.request("bad_place") + except AuthorizationException as ex: + assert ex.message == "You do not have permission to access this resource." + assert ex.code == "forbidden" + assert ex.request_id == request_id assert ex.__class__ == AuthorizationException + assert not isinstance(ex, EmailVerificationRequiredException) def test_request_includes_base_headers(self, capture_and_mock_http_client_request): request_kwargs = capture_and_mock_http_client_request(self.http_client, {}, 200) diff --git a/workos/exceptions.py b/workos/exceptions.py index 7ec4e90c..a79e1159 100644 --- a/workos/exceptions.py +++ b/workos/exceptions.py @@ -20,9 +20,6 @@ def __init__( self.errors = self.extract_from_json("errors", None) self.code = self.extract_from_json("code", None) self.error_description = self.extract_from_json("error_description", "Unknown") - self.email_verification_id = self.extract_from_json( - "email_verification_id", None - ) self.request_id = response.headers.get("X-Request-ID") @@ -48,6 +45,24 @@ class AuthorizationException(BaseRequestException): pass +class EmailVerificationRequiredException(AuthorizationException): + """Raised when email verification is required before authentication. + + This exception includes an email_verification_id field that can be used + to retrieve the email verification object or resend the verification email. + """ + + def __init__( + self, + response: httpx.Response, + response_json: Optional[Mapping[str, Any]], + ) -> None: + super().__init__(response, response_json) + self.email_verification_id = self.extract_from_json( + "email_verification_id", None + ) + + class AuthenticationException(BaseRequestException): pass diff --git a/workos/utils/_base_http_client.py b/workos/utils/_base_http_client.py index a9ab0c55..49dcbcf5 100644 --- a/workos/utils/_base_http_client.py +++ b/workos/utils/_base_http_client.py @@ -20,6 +20,7 @@ ServerException, AuthenticationException, AuthorizationException, + EmailVerificationRequiredException, NotFoundException, BadRequestException, ) @@ -99,6 +100,11 @@ def _maybe_raise_error_by_status_code( if status_code == 401: raise AuthenticationException(response, response_json) elif status_code == 403: + if ( + response_json is not None + and response_json.get("code") == "email_verification_required" + ): + raise EmailVerificationRequiredException(response, response_json) raise AuthorizationException(response, response_json) elif status_code == 404: raise NotFoundException(response, response_json)