This is an automated email from the ASF dual-hosted git repository.

janhoy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-orbit.git


The following commit(s) were added to refs/heads/main by this push:
     new d8788eff Ask Solr which create-collection payload it takes (#112)
d8788eff is described below

commit d8788eff2516df0acfa15a37e90fc56740c0deab
Author: Jan Høydahl <[email protected]>
AuthorDate: Fri Oct 16 09:21:44 2026 +0200

    Ask Solr which create-collection payload it takes (#112)
---
 solrorbit/client.py  | 39 ++++++++++++++++++++++++++++-----
 tests/client_test.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 96 insertions(+), 5 deletions(-)

diff --git a/solrorbit/client.py b/solrorbit/client.py
index 73538718..3724cfe4 100644
--- a/solrorbit/client.py
+++ b/solrorbit/client.py
@@ -212,11 +212,7 @@ class SolrAdminClient:
             "pullReplicas": pull_replicas,
             "waitForFinalState": True,
         }
-        resp = self._get_session().post(
-            f"{self.api_url}/collections",
-            json=payload,
-            timeout=self.timeout,
-        )
+        resp = self._post_collection(payload)
         if resp.status_code == 400:
             body = self._try_parse_json(resp)
             if "already exists" in str(body).lower():
@@ -227,6 +223,39 @@ class SolrAdminClient:
         logger.info("Created collection '%s' (shards=%d, nrt=%d, tlog=%d, 
pull=%d)",
                     name, num_shards, replication_factor, tlog_replicas, 
pull_replicas)
 
+    def _post_collection(self, payload: dict):
+        """POST a create-collection body, in whichever shape this Solr takes.
+
+        Solr reworked its V2 API: newer releases take the collection's
+        properties as the request body, older ones take a command envelope,
+        ``{"create": {...}}``. Benchmarking a release line means talking to 
both
+        -- a 9.0 cluster answers the newer shape with
+        ``Unknown operation 'name', available ops are [... create ...]``, which
+        is Solr telling us exactly which shape it wanted.
+
+        Asking the version instead would mean keeping a table of which release
+        changed, and being wrong about a release nobody tested. Letting Solr
+        say so costs one extra request, once, and only on the clusters that
+        need it.
+        """
+        session = self._get_session()
+        url = f"{self.api_url}/collections"
+        resp = session.post(url, json=payload, timeout=self.timeout)
+        if resp.status_code == 400 and self._wants_command_envelope(resp):
+            logger.debug("Retrying create-collection with the pre-rework V2 
command envelope")
+            resp = session.post(url, json={"create": payload}, 
timeout=self.timeout)
+        return resp
+
+    @staticmethod
+    def _wants_command_envelope(resp) -> bool:
+        """Whether a 400 is Solr saying "that is not how I take commands"."""
+        try:
+            body = resp.json()
+        except ValueError:
+            return False
+        text = str(body).lower()
+        return "unknown operation" in text and "create" in text
+
     def delete_collection(self, name: str) -> None:
         """Delete a Solr collection via DELETE /api/collections/{name}."""
         resp = self._get_session().delete(
diff --git a/tests/client_test.py b/tests/client_test.py
index e99fb8b3..140cf088 100644
--- a/tests/client_test.py
+++ b/tests/client_test.py
@@ -54,3 +54,65 @@ class RequestContextManagerTests(TestCase):
         self.assertAlmostEqual(top_level_duration, 0.2, delta=0.05)
         # nested request should only cover nested duration
         self.assertAlmostEqual(nested_duration, 0.1, delta=0.05)
+
+
+class CreateCollectionApiShapeTests(TestCase):
+    """Solr reworked its V2 API: newer releases take the collection's
+    properties as the request body, older ones take a ``{"create": {...}}``
+    envelope. Benchmarking a release line means talking to both."""
+
+    class FakeResponse:
+        def __init__(self, status_code, body=None):
+            self.status_code = status_code
+            self._body = body if body is not None else {}
+            self.text = str(self._body)
+            self.ok = status_code < 400
+
+        def json(self):
+            return self._body
+
+    class FakeSession:
+        def __init__(self, reject_first):
+            self.reject_first = reject_first
+            self.posts = []
+
+        def post(self, url, json=None, timeout=None):
+            self.posts.append(json)
+            if self.reject_first and len(self.posts) == 1:
+                return CreateCollectionApiShapeTests.FakeResponse(400, {
+                    "error": {"details": [{"errorMessages": [
+                        "Unknown operation 'name' available ops are "
+                        "'[set-alias-property, delete-alias, create, 
create-alias]'"]}]}})
+            return CreateCollectionApiShapeTests.FakeResponse(200, 
{"responseHeader": {"status": 0}})
+
+    def client_with(self, session):
+        admin = client.SolrAdminClient("localhost")
+        admin._session = session
+        return admin
+
+    def test_a_modern_solr_is_asked_once(self):
+        session = self.FakeSession(reject_first=False)
+        self.client_with(session).create_collection("c", configset="c")
+        self.assertEqual(1, len(session.posts))
+        self.assertEqual("c", session.posts[0]["name"])
+
+    def test_an_older_solr_is_retried_with_the_command_envelope(self):
+        session = self.FakeSession(reject_first=True)
+        self.client_with(session).create_collection("c", configset="c", 
num_shards=2)
+        self.assertEqual(2, len(session.posts))
+        self.assertNotIn("create", session.posts[0])
+        self.assertEqual(2, session.posts[1]["create"]["numShards"])
+
+    def test_an_unrelated_400_is_not_retried(self):
+        # Only "unknown operation" means Solr wanted a different shape; every
+        # other 400 is a real failure and must surface as one.
+        session = self.FakeSession(reject_first=False)
+
+        def post(url, json=None, timeout=None):
+            session.posts.append(json)
+            return self.FakeResponse(400, {"error": {"msg": "Collection 'c' 
already exists"}})
+
+        session.post = post
+        with self.assertRaises(client.CollectionAlreadyExistsError):
+            self.client_with(session).create_collection("c", configset="c")
+        self.assertEqual(1, len(session.posts))

Reply via email to