From 606e2be35289bc69633e879421b5fa7da764084a Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 10:24:51 -0500 Subject: [PATCH 01/13] begin throttling due to massive influx of crud --- warehouse/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/warehouse/config.py b/warehouse/config.py index 5ed680a5b91e..ff2a60c8a790 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -576,7 +576,7 @@ def configure(settings=None): settings, "warehouse.packaging.project_create_user_ratelimit_string", "PROJECT_CREATE_USER_RATELIMIT_STRING", - default="20 per hour", + default="2 per day", ) maybe_set( settings, From 2a0cc2db90f87f54fd9970dbfbc7e51c769f4f1a Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 10:33:15 -0500 Subject: [PATCH 02/13] also limit trusted pub and org proj. creation --- warehouse/locale/messages.pot | 30 ++++++++++++------------- warehouse/manage/views/organizations.py | 1 - warehouse/oidc/views.py | 1 - 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 8efdfe806146..2fa172bf2d5d 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -339,14 +339,14 @@ msgstr "" #: warehouse/accounts/views.py:1794 warehouse/accounts/views.py:2057 #: warehouse/manage/views/oidc_publishers.py:126 -#: warehouse/manage/views/organizations.py:1820 +#: warehouse/manage/views/organizations.py:1819 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" #: warehouse/accounts/views.py:1815 -#: warehouse/manage/views/organizations.py:1843 +#: warehouse/manage/views/organizations.py:1842 msgid "disabled. See https://pypi.org/help#admin-intervention for details." msgstr "" @@ -375,7 +375,7 @@ msgstr "" #: warehouse/manage/views/oidc_publishers.py:436 #: warehouse/manage/views/oidc_publishers.py:552 #: warehouse/manage/views/oidc_publishers.py:664 -#: warehouse/manage/views/organizations.py:1859 +#: warehouse/manage/views/organizations.py:1858 msgid "The trusted publisher could not be registered" msgstr "" @@ -393,7 +393,7 @@ msgid "" msgstr "" #: warehouse/accounts/views.py:1927 -#: warehouse/manage/views/organizations.py:1908 +#: warehouse/manage/views/organizations.py:1907 msgid "Registered a new pending publisher to create " msgstr "" @@ -661,13 +661,13 @@ msgid "" msgstr "" #: warehouse/manage/views/__init__.py:2259 -#: warehouse/manage/views/organizations.py:980 +#: warehouse/manage/views/organizations.py:979 #, python-brace-format msgid "User '${username}' already has an active invite. Please try again later." msgstr "" #: warehouse/manage/views/__init__.py:2324 -#: warehouse/manage/views/organizations.py:1055 +#: warehouse/manage/views/organizations.py:1054 #, python-brace-format msgid "Invitation sent to '${username}'" msgstr "" @@ -681,7 +681,7 @@ msgid "Invitation already expired." msgstr "" #: warehouse/manage/views/__init__.py:2400 -#: warehouse/manage/views/organizations.py:1255 +#: warehouse/manage/views/organizations.py:1254 #, python-brace-format msgid "Invitation revoked from '${username}'." msgstr "" @@ -774,37 +774,37 @@ msgid "" "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/organizations.py:956 +#: warehouse/manage/views/organizations.py:955 #, python-brace-format msgid "User '${username}' already has ${role_name} role for organization" msgstr "" -#: warehouse/manage/views/organizations.py:967 +#: warehouse/manage/views/organizations.py:966 #, python-brace-format msgid "" "User '${username}' does not have a verified primary email address and " "cannot be added as a ${role_name} for organization" msgstr "" -#: warehouse/manage/views/organizations.py:991 +#: warehouse/manage/views/organizations.py:990 msgid "Cannot invite new member. Organization is not in good standing." msgstr "" -#: warehouse/manage/views/organizations.py:1150 -#: warehouse/manage/views/organizations.py:1192 +#: warehouse/manage/views/organizations.py:1149 +#: warehouse/manage/views/organizations.py:1191 msgid "Could not find organization invitation." msgstr "" -#: warehouse/manage/views/organizations.py:1160 +#: warehouse/manage/views/organizations.py:1159 msgid "Organization invitation could not be re-sent." msgstr "" -#: warehouse/manage/views/organizations.py:1208 +#: warehouse/manage/views/organizations.py:1207 #, python-brace-format msgid "Expired invitation for '${username}' deleted." msgstr "" -#: warehouse/manage/views/organizations.py:1875 +#: warehouse/manage/views/organizations.py:1874 msgid "" "This publisher has already been registered in your organization. See your" " existing pending publishers below." diff --git a/warehouse/manage/views/organizations.py b/warehouse/manage/views/organizations.py index 3903b401fdb9..b0f9804c1443 100644 --- a/warehouse/manage/views/organizations.py +++ b/warehouse/manage/views/organizations.py @@ -881,7 +881,6 @@ def add_organization_project(self): self.request.user, request=self.request, creator_is_owner=False, - ratelimited=False, ) except HTTPException as exc: form.new_project_name.errors.append(exc.detail) diff --git a/warehouse/oidc/views.py b/warehouse/oidc/views.py index 00d20899d44d..08e72e057448 100644 --- a/warehouse/oidc/views.py +++ b/warehouse/oidc/views.py @@ -225,7 +225,6 @@ def mint_token( pending_publisher.added_by, request, creator_is_owner=pending_publisher.organization_id is None, - ratelimited=False, organization_id=pending_publisher.organization_id, ) except HTTPException as exc: From dcf060de298abfb93d46abcb427bc87db85efcfd Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 10:33:27 -0500 Subject: [PATCH 03/13] fix: test --- tests/unit/test_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index ea38dc019009..4bdbbec0a102 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -325,7 +325,7 @@ def __init__(self): "warehouse.account.password_reset_ratelimit_string": "5 per day", "warehouse.manage.oidc.user_registration_ratelimit_string": "100 per day", "warehouse.manage.oidc.ip_registration_ratelimit_string": "100 per day", - "warehouse.packaging.project_create_user_ratelimit_string": "20 per hour", + "warehouse.packaging.project_create_user_ratelimit_string": "2 per day", "warehouse.packaging.project_create_ip_ratelimit_string": "40 per hour", "warehouse.search.ratelimit_string": "5 per second", "oidc.backend": "warehouse.oidc.services.OIDCPublisherService", From 00f028a885be31e148830f918e2ec944cfe00e32 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 10:46:06 -0500 Subject: [PATCH 04/13] we dont have this anymore so the test tree fails, fixing --- tests/unit/packaging/test_services.py | 2 +- warehouse/packaging/services.py | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/unit/packaging/test_services.py b/tests/unit/packaging/test_services.py index 9da1ee769e48..aac02a41dde2 100644 --- a/tests/unit/packaging/test_services.py +++ b/tests/unit/packaging/test_services.py @@ -1030,7 +1030,7 @@ def test_check_project_test_new_disallowed(self, db_request): service = ProjectService(session=db_request.db) with pytest.raises(HTTPForbidden) as exc: - service.create_project("foo", pretend.stub(), db_request, ratelimited=False) + service.create_project("foo", pretend.stub(), db_request) resp = exc.value assert resp.status_code == 403 diff --git a/warehouse/packaging/services.py b/warehouse/packaging/services.py index 7626885379f4..793a5b233132 100644 --- a/warehouse/packaging/services.py +++ b/warehouse/packaging/services.py @@ -500,11 +500,9 @@ def create_project( request, *, creator_is_owner=True, - ratelimited=True, organization_id=None, ): - if ratelimited: - self._check_ratelimits(request, creator) + self._check_ratelimits(request, creator) # Check for AdminFlag set by a PyPI Administrator disabling new project # registration, reasons for this include Spammers, security @@ -716,8 +714,7 @@ def create_project( ) request.db.delete(stale_publisher) - if ratelimited: - self._hit_ratelimits(request, creator) + self._hit_ratelimits(request, creator) return project From 51a595638f5811aee4a222bdd1174dbc0dab4961 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 10:51:44 -0500 Subject: [PATCH 05/13] revert limits, but they will be lowered in infra env settings --- tests/unit/test_config.py | 2 +- warehouse/config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 4bdbbec0a102..ea38dc019009 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -325,7 +325,7 @@ def __init__(self): "warehouse.account.password_reset_ratelimit_string": "5 per day", "warehouse.manage.oidc.user_registration_ratelimit_string": "100 per day", "warehouse.manage.oidc.ip_registration_ratelimit_string": "100 per day", - "warehouse.packaging.project_create_user_ratelimit_string": "2 per day", + "warehouse.packaging.project_create_user_ratelimit_string": "20 per hour", "warehouse.packaging.project_create_ip_ratelimit_string": "40 per hour", "warehouse.search.ratelimit_string": "5 per second", "oidc.backend": "warehouse.oidc.services.OIDCPublisherService", diff --git a/warehouse/config.py b/warehouse/config.py index ff2a60c8a790..5ed680a5b91e 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -576,7 +576,7 @@ def configure(settings=None): settings, "warehouse.packaging.project_create_user_ratelimit_string", "PROJECT_CREATE_USER_RATELIMIT_STRING", - default="2 per day", + default="20 per hour", ) maybe_set( settings, From fd28ee59d6d5c1e5943fb0ca7208f08d260ba47f Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 11:10:14 -0500 Subject: [PATCH 06/13] fix: give creator stub an id for unconditional ratelimit check _check_ratelimits now runs on every create_project call, which evaluates creator.id to pass to the limiter. The bare pretend.stub() has no id attribute, so the test blew up before reaching the AdminFlag-forbidden path it was meant to exercise. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/packaging/test_services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/packaging/test_services.py b/tests/unit/packaging/test_services.py index aac02a41dde2..c1d632d956af 100644 --- a/tests/unit/packaging/test_services.py +++ b/tests/unit/packaging/test_services.py @@ -1030,7 +1030,7 @@ def test_check_project_test_new_disallowed(self, db_request): service = ProjectService(session=db_request.db) with pytest.raises(HTTPForbidden) as exc: - service.create_project("foo", pretend.stub(), db_request) + service.create_project("foo", pretend.stub(id=pretend.stub()), db_request) resp = exc.value assert resp.status_code == 403 From 41a6bd1be341f5cba7020a39552bc4d18e0935b1 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 11:23:33 -0500 Subject: [PATCH 07/13] return exc for toomany created instead of some opaque error --- warehouse/locale/messages.pot | 30 ++++++++++++------------- warehouse/manage/views/organizations.py | 7 ++++++ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 2fa172bf2d5d..58abccb9eea5 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -339,14 +339,14 @@ msgstr "" #: warehouse/accounts/views.py:1794 warehouse/accounts/views.py:2057 #: warehouse/manage/views/oidc_publishers.py:126 -#: warehouse/manage/views/organizations.py:1819 +#: warehouse/manage/views/organizations.py:1826 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" #: warehouse/accounts/views.py:1815 -#: warehouse/manage/views/organizations.py:1842 +#: warehouse/manage/views/organizations.py:1849 msgid "disabled. See https://pypi.org/help#admin-intervention for details." msgstr "" @@ -375,7 +375,7 @@ msgstr "" #: warehouse/manage/views/oidc_publishers.py:436 #: warehouse/manage/views/oidc_publishers.py:552 #: warehouse/manage/views/oidc_publishers.py:664 -#: warehouse/manage/views/organizations.py:1858 +#: warehouse/manage/views/organizations.py:1865 msgid "The trusted publisher could not be registered" msgstr "" @@ -393,7 +393,7 @@ msgid "" msgstr "" #: warehouse/accounts/views.py:1927 -#: warehouse/manage/views/organizations.py:1907 +#: warehouse/manage/views/organizations.py:1914 msgid "Registered a new pending publisher to create " msgstr "" @@ -661,13 +661,13 @@ msgid "" msgstr "" #: warehouse/manage/views/__init__.py:2259 -#: warehouse/manage/views/organizations.py:979 +#: warehouse/manage/views/organizations.py:986 #, python-brace-format msgid "User '${username}' already has an active invite. Please try again later." msgstr "" #: warehouse/manage/views/__init__.py:2324 -#: warehouse/manage/views/organizations.py:1054 +#: warehouse/manage/views/organizations.py:1061 #, python-brace-format msgid "Invitation sent to '${username}'" msgstr "" @@ -681,7 +681,7 @@ msgid "Invitation already expired." msgstr "" #: warehouse/manage/views/__init__.py:2400 -#: warehouse/manage/views/organizations.py:1254 +#: warehouse/manage/views/organizations.py:1261 #, python-brace-format msgid "Invitation revoked from '${username}'." msgstr "" @@ -774,37 +774,37 @@ msgid "" "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/organizations.py:955 +#: warehouse/manage/views/organizations.py:962 #, python-brace-format msgid "User '${username}' already has ${role_name} role for organization" msgstr "" -#: warehouse/manage/views/organizations.py:966 +#: warehouse/manage/views/organizations.py:973 #, python-brace-format msgid "" "User '${username}' does not have a verified primary email address and " "cannot be added as a ${role_name} for organization" msgstr "" -#: warehouse/manage/views/organizations.py:990 +#: warehouse/manage/views/organizations.py:997 msgid "Cannot invite new member. Organization is not in good standing." msgstr "" -#: warehouse/manage/views/organizations.py:1149 -#: warehouse/manage/views/organizations.py:1191 +#: warehouse/manage/views/organizations.py:1156 +#: warehouse/manage/views/organizations.py:1198 msgid "Could not find organization invitation." msgstr "" -#: warehouse/manage/views/organizations.py:1159 +#: warehouse/manage/views/organizations.py:1166 msgid "Organization invitation could not be re-sent." msgstr "" -#: warehouse/manage/views/organizations.py:1207 +#: warehouse/manage/views/organizations.py:1214 #, python-brace-format msgid "Expired invitation for '${username}' deleted." msgstr "" -#: warehouse/manage/views/organizations.py:1874 +#: warehouse/manage/views/organizations.py:1881 msgid "" "This publisher has already been registered in your organization. See your" " existing pending publishers below." diff --git a/warehouse/manage/views/organizations.py b/warehouse/manage/views/organizations.py index b0f9804c1443..b5898317401b 100644 --- a/warehouse/manage/views/organizations.py +++ b/warehouse/manage/views/organizations.py @@ -80,6 +80,7 @@ TermsOfServiceEngagement, ) from warehouse.packaging import IProjectService, Project, Role +from warehouse.packaging.interfaces import TooManyProjectsCreated from warehouse.packaging.models import JournalEntry, ProjectFactory from warehouse.subscriptions import IBillingService, ISubscriptionService from warehouse.subscriptions.services import MockStripeBillingService @@ -885,6 +886,12 @@ def add_organization_project(self): except HTTPException as exc: form.new_project_name.errors.append(exc.detail) return default_response + except TooManyProjectsCreated as exc: + form.new_project_name.errors.append( + "Too many new projects created. Try again in " + f"{int(exc.resets_in.total_seconds())} seconds." + ) + return default_response # Add project to organization. self.organization_service.add_organization_project( From a16238748e22eaa73beb508b159950f0fa5ac882 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 11:23:51 -0500 Subject: [PATCH 08/13] return exc for toomany created instead of some opaque error here too --- warehouse/oidc/views.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/warehouse/oidc/views.py b/warehouse/oidc/views.py index 08e72e057448..ca59f431dfd4 100644 --- a/warehouse/oidc/views.py +++ b/warehouse/oidc/views.py @@ -32,7 +32,7 @@ OIDC_ISSUER_SERVICE_NAMES, lookup_custom_issuer_type, ) -from warehouse.packaging.interfaces import IProjectService +from warehouse.packaging.interfaces import IProjectService, TooManyProjectsCreated from warehouse.packaging.models import ProjectFactory from warehouse.rate_limiting.interfaces import IRateLimiter @@ -232,6 +232,20 @@ def mint_token( errors=[{"code": "invalid-payload", "description": str(exc)}], request=request, ) + except TooManyProjectsCreated as exc: + return _invalid( + errors=[ + { + "code": "too-many-projects", + "description": ( + "Too many new projects created. " + "Try again in " + f"{int(exc.resets_in.total_seconds())} seconds." + ), + } + ], + request=request, + ) # Reify the pending publisher against the newly created project reified_publisher = oidc_service.reify_pending_publisher( From 38ae2e799ea6d4b4e179f29265d1d7f9ff770dda Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 11:25:19 -0500 Subject: [PATCH 09/13] test: cover TooManyProjectsCreated paths for org and trusted-pub creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds regression coverage for the two view-layer catches added in 41a6bd1be / a16238748 — confirms that hitting the per-user project-create ratelimiter while creating an org-owned project or reifying a pending OIDC publisher returns a structured error (form-error / 422 JSON) instead of a 500. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/manage/views/test_organizations.py | 51 ++++++++++++++++++- tests/unit/oidc/test_views.py | 45 +++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/tests/unit/manage/views/test_organizations.py b/tests/unit/manage/views/test_organizations.py index 469e1f5c45e4..4cd02f35917b 100644 --- a/tests/unit/manage/views/test_organizations.py +++ b/tests/unit/manage/views/test_organizations.py @@ -50,7 +50,8 @@ OrganizationRoleType, OrganizationType, ) -from warehouse.packaging import Project +from warehouse.packaging import IProjectService, Project +from warehouse.packaging.interfaces import TooManyProjectsCreated from warehouse.utils.paginate import paginate_url_factory @@ -2135,6 +2136,54 @@ def test_add_organization_project_new_project_name_conflict( ] assert len(organization.projects) == 1 + @pytest.mark.usefixtures("_enable_organizations") + def test_add_organization_project_new_project_ratelimited( + self, + db_request, + pyramid_user, + organization_service, + monkeypatch, + ): + organization = OrganizationFactory.create() + OrganizationRoleFactory.create( + organization=organization, user=db_request.user, role_name="Owner" + ) + + add_organization_project_obj = pretend.stub( + add_existing_project=pretend.stub(data=False), + new_project_name=pretend.stub(data="some-new-project", errors=[]), + validate=lambda *a, **kw: True, + ) + monkeypatch.setattr( + org_views, + "AddOrganizationProjectForm", + lambda *a, **kw: add_organization_project_obj, + ) + + project_service = pretend.stub( + create_project=pretend.raiser( + TooManyProjectsCreated(resets_in=datetime.timedelta(seconds=42)) + ), + ) + db_request.find_service = lambda svc, **kw: ( + project_service if svc is IProjectService else organization_service + ) + + view = org_views.ManageOrganizationProjectsViews(organization, db_request) + result = view.add_organization_project() + + assert result == { + "organization": organization, + "active_projects": view.active_projects, + "projects_owned": set(), + "projects_sole_owned": set(), + "add_organization_project_form": add_organization_project_obj, + } + assert add_organization_project_obj.new_project_name.errors == [ + "Too many new projects created. Try again in 42 seconds." + ] + assert len(organization.projects) == 0 + class TestManageOrganizationRoles: @pytest.mark.usefixtures("_enable_organizations") diff --git a/tests/unit/oidc/test_views.py b/tests/unit/oidc/test_views.py index cb9b1fb0970d..137ebd8bf2b0 100644 --- a/tests/unit/oidc/test_views.py +++ b/tests/unit/oidc/test_views.py @@ -2,7 +2,7 @@ import json -from datetime import datetime +from datetime import datetime, timedelta import pretend import pytest @@ -30,6 +30,7 @@ ) from warehouse.organizations.models import OrganizationProject from warehouse.packaging import services +from warehouse.packaging.interfaces import IProjectService, TooManyProjectsCreated from warehouse.packaging.models import Project from warehouse.rate_limiting.interfaces import IRateLimiter @@ -456,6 +457,48 @@ def test_mint_token_pending_publisher_project_already_exists(db_request): assert oidc_service.find_publisher.calls == [pretend.call(claims, pending=True)] +def test_mint_token_pending_publisher_project_create_ratelimited(db_request): + pending_publisher = PendingGitHubPublisherFactory.create( + project_name="does-not-exist", + ) + + db_request.flags.disallow_oidc = lambda f=None: False + + claims = {"iss": "https://none"} + oidc_service = pretend.stub( + verify_jwt_signature=pretend.call_recorder( + lambda token, issuer_url=None: claims + ), + find_publisher=pretend.call_recorder( + lambda claims, pending=False: pending_publisher + ), + ) + project_service = pretend.stub( + create_project=pretend.raiser( + TooManyProjectsCreated(resets_in=timedelta(seconds=42)) + ), + ) + db_request.find_service = lambda svc, **kw: ( + project_service if svc is IProjectService else oidc_service + ) + + resp = views.mint_token( + oidc_service, DUMMY_GITHUB_OIDC_JWT, claims["iss"], db_request + ) + assert db_request.response.status_code == 422 + assert resp == { + "message": "Token request failed", + "errors": [ + { + "code": "too-many-projects", + "description": ( + "Too many new projects created. Try again in 42 seconds." + ), + } + ], + } + + def test_mint_token_from_oidc_pending_publisher_ok(monkeypatch, db_request): user = UserFactory.create() From 4146bd41e70c29c47b33091fa76ed68c4020f3c9 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 11:40:15 -0500 Subject: [PATCH 10/13] address review concerns around retry data --- warehouse/locale/messages.pot | 30 ++++++++++++------------- warehouse/manage/views/organizations.py | 8 +++++-- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 58abccb9eea5..710ec8df0aa0 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -339,14 +339,14 @@ msgstr "" #: warehouse/accounts/views.py:1794 warehouse/accounts/views.py:2057 #: warehouse/manage/views/oidc_publishers.py:126 -#: warehouse/manage/views/organizations.py:1826 +#: warehouse/manage/views/organizations.py:1830 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" #: warehouse/accounts/views.py:1815 -#: warehouse/manage/views/organizations.py:1849 +#: warehouse/manage/views/organizations.py:1853 msgid "disabled. See https://pypi.org/help#admin-intervention for details." msgstr "" @@ -375,7 +375,7 @@ msgstr "" #: warehouse/manage/views/oidc_publishers.py:436 #: warehouse/manage/views/oidc_publishers.py:552 #: warehouse/manage/views/oidc_publishers.py:664 -#: warehouse/manage/views/organizations.py:1865 +#: warehouse/manage/views/organizations.py:1869 msgid "The trusted publisher could not be registered" msgstr "" @@ -393,7 +393,7 @@ msgid "" msgstr "" #: warehouse/accounts/views.py:1927 -#: warehouse/manage/views/organizations.py:1914 +#: warehouse/manage/views/organizations.py:1918 msgid "Registered a new pending publisher to create " msgstr "" @@ -661,13 +661,13 @@ msgid "" msgstr "" #: warehouse/manage/views/__init__.py:2259 -#: warehouse/manage/views/organizations.py:986 +#: warehouse/manage/views/organizations.py:990 #, python-brace-format msgid "User '${username}' already has an active invite. Please try again later." msgstr "" #: warehouse/manage/views/__init__.py:2324 -#: warehouse/manage/views/organizations.py:1061 +#: warehouse/manage/views/organizations.py:1065 #, python-brace-format msgid "Invitation sent to '${username}'" msgstr "" @@ -681,7 +681,7 @@ msgid "Invitation already expired." msgstr "" #: warehouse/manage/views/__init__.py:2400 -#: warehouse/manage/views/organizations.py:1261 +#: warehouse/manage/views/organizations.py:1265 #, python-brace-format msgid "Invitation revoked from '${username}'." msgstr "" @@ -774,37 +774,37 @@ msgid "" "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/organizations.py:962 +#: warehouse/manage/views/organizations.py:966 #, python-brace-format msgid "User '${username}' already has ${role_name} role for organization" msgstr "" -#: warehouse/manage/views/organizations.py:973 +#: warehouse/manage/views/organizations.py:977 #, python-brace-format msgid "" "User '${username}' does not have a verified primary email address and " "cannot be added as a ${role_name} for organization" msgstr "" -#: warehouse/manage/views/organizations.py:997 +#: warehouse/manage/views/organizations.py:1001 msgid "Cannot invite new member. Organization is not in good standing." msgstr "" -#: warehouse/manage/views/organizations.py:1156 -#: warehouse/manage/views/organizations.py:1198 +#: warehouse/manage/views/organizations.py:1160 +#: warehouse/manage/views/organizations.py:1202 msgid "Could not find organization invitation." msgstr "" -#: warehouse/manage/views/organizations.py:1166 +#: warehouse/manage/views/organizations.py:1170 msgid "Organization invitation could not be re-sent." msgstr "" -#: warehouse/manage/views/organizations.py:1214 +#: warehouse/manage/views/organizations.py:1218 #, python-brace-format msgid "Expired invitation for '${username}' deleted." msgstr "" -#: warehouse/manage/views/organizations.py:1881 +#: warehouse/manage/views/organizations.py:1885 msgid "" "This publisher has already been registered in your organization. See your" " existing pending publishers below." diff --git a/warehouse/manage/views/organizations.py b/warehouse/manage/views/organizations.py index b5898317401b..9a3f11655be0 100644 --- a/warehouse/manage/views/organizations.py +++ b/warehouse/manage/views/organizations.py @@ -887,9 +887,13 @@ def add_organization_project(self): form.new_project_name.errors.append(exc.detail) return default_response except TooManyProjectsCreated as exc: + retry = ( + f"Try again in {int(exc.resets_in.total_seconds())} seconds." + if exc.resets_in is not None + else "Try again later." + ) form.new_project_name.errors.append( - "Too many new projects created. Try again in " - f"{int(exc.resets_in.total_seconds())} seconds." + f"Too many new projects created. {retry}" ) return default_response From fb9633b919567b0d9ca31ea5b8c85a611799273d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 11:40:30 -0500 Subject: [PATCH 11/13] address review concerns around retry data --- warehouse/oidc/views.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/warehouse/oidc/views.py b/warehouse/oidc/views.py index ca59f431dfd4..7a426b965c4d 100644 --- a/warehouse/oidc/views.py +++ b/warehouse/oidc/views.py @@ -233,15 +233,16 @@ def mint_token( request=request, ) except TooManyProjectsCreated as exc: + retry = ( + f"Try again in {int(exc.resets_in.total_seconds())} seconds." + if exc.resets_in is not None + else "Try again later." + ) return _invalid( errors=[ { "code": "too-many-projects", - "description": ( - "Too many new projects created. " - "Try again in " - f"{int(exc.resets_in.total_seconds())} seconds." - ), + "description": f"Too many new projects created. {retry}", } ], request=request, From 91a23fcf6e51490eceb87f1962e65e3e2d4b8d44 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 11:43:30 -0500 Subject: [PATCH 12/13] fix: query user bucket by creator.id, not remote_addr --- warehouse/packaging/services.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/warehouse/packaging/services.py b/warehouse/packaging/services.py index 793a5b233132..d3a0f00c033d 100644 --- a/warehouse/packaging/services.py +++ b/warehouse/packaging/services.py @@ -437,9 +437,7 @@ def _check_ratelimits(self, request, creator): tags=["ratelimiter:user"], ) raise TooManyProjectsCreated( - resets_in=self.ratelimiters["project.create.user"].resets_in( - request.remote_addr - ) + resets_in=self.ratelimiters["project.create.user"].resets_in(creator.id) ) def _hit_ratelimits(self, request, creator): From ee55d9891946c01690df4f0e3c225463e5449dcf Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 11:47:57 -0500 Subject: [PATCH 13/13] test: cover None resets_in path for project-create ratelimit handlers Parametrize the new TooManyProjectsCreated regression tests so they exercise both a real timedelta and the resets_in=None branch the defensive handlers in oidc/views.py and manage/views/organizations.py fall back to. None can legitimately come back from the limiter on a race or a transient redis error, and we want a 429 with "try again later" instead of a 500. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/manage/views/test_organizations.py | 20 ++++++++++++----- tests/unit/oidc/test_views.py | 22 +++++++++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/tests/unit/manage/views/test_organizations.py b/tests/unit/manage/views/test_organizations.py index 4cd02f35917b..b5532cd2c061 100644 --- a/tests/unit/manage/views/test_organizations.py +++ b/tests/unit/manage/views/test_organizations.py @@ -2137,12 +2137,24 @@ def test_add_organization_project_new_project_name_conflict( assert len(organization.projects) == 1 @pytest.mark.usefixtures("_enable_organizations") + @pytest.mark.parametrize( + ("resets_in", "expected_error"), + [ + ( + datetime.timedelta(seconds=42), + "Too many new projects created. Try again in 42 seconds.", + ), + (None, "Too many new projects created. Try again later."), + ], + ) def test_add_organization_project_new_project_ratelimited( self, db_request, pyramid_user, organization_service, monkeypatch, + resets_in, + expected_error, ): organization = OrganizationFactory.create() OrganizationRoleFactory.create( @@ -2161,9 +2173,7 @@ def test_add_organization_project_new_project_ratelimited( ) project_service = pretend.stub( - create_project=pretend.raiser( - TooManyProjectsCreated(resets_in=datetime.timedelta(seconds=42)) - ), + create_project=pretend.raiser(TooManyProjectsCreated(resets_in=resets_in)), ) db_request.find_service = lambda svc, **kw: ( project_service if svc is IProjectService else organization_service @@ -2179,9 +2189,7 @@ def test_add_organization_project_new_project_ratelimited( "projects_sole_owned": set(), "add_organization_project_form": add_organization_project_obj, } - assert add_organization_project_obj.new_project_name.errors == [ - "Too many new projects created. Try again in 42 seconds." - ] + assert add_organization_project_obj.new_project_name.errors == [expected_error] assert len(organization.projects) == 0 diff --git a/tests/unit/oidc/test_views.py b/tests/unit/oidc/test_views.py index 137ebd8bf2b0..07f28880abfd 100644 --- a/tests/unit/oidc/test_views.py +++ b/tests/unit/oidc/test_views.py @@ -457,7 +457,19 @@ def test_mint_token_pending_publisher_project_already_exists(db_request): assert oidc_service.find_publisher.calls == [pretend.call(claims, pending=True)] -def test_mint_token_pending_publisher_project_create_ratelimited(db_request): +@pytest.mark.parametrize( + ("resets_in", "expected_description"), + [ + ( + timedelta(seconds=42), + "Too many new projects created. Try again in 42 seconds.", + ), + (None, "Too many new projects created. Try again later."), + ], +) +def test_mint_token_pending_publisher_project_create_ratelimited( + db_request, resets_in, expected_description +): pending_publisher = PendingGitHubPublisherFactory.create( project_name="does-not-exist", ) @@ -474,9 +486,7 @@ def test_mint_token_pending_publisher_project_create_ratelimited(db_request): ), ) project_service = pretend.stub( - create_project=pretend.raiser( - TooManyProjectsCreated(resets_in=timedelta(seconds=42)) - ), + create_project=pretend.raiser(TooManyProjectsCreated(resets_in=resets_in)), ) db_request.find_service = lambda svc, **kw: ( project_service if svc is IProjectService else oidc_service @@ -491,9 +501,7 @@ def test_mint_token_pending_publisher_project_create_ratelimited(db_request): "errors": [ { "code": "too-many-projects", - "description": ( - "Too many new projects created. Try again in 42 seconds." - ), + "description": expected_description, } ], }