From 545ea9ce272bb8d80671445369832f4ea6bb6a3a Mon Sep 17 00:00:00 2001 From: "gocardless-ci-robot[bot]" <123969075+gocardless-ci-robot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:43:49 +0000 Subject: [PATCH] Changes generated by 7c980bd045d8be6eed16fe98896523b118a0578e This commit was automatically created from gocardless/client-library-templates@7c980bd045d8be6eed16fe98896523b118a0578e by the `push-files` action. Workflow run: https://github.com/gocardless/client-library-templates/actions/runs/37431151926 --- ...nk_account_holder_verifications_service.py | 4 +- .../services/bank_authorisations_service.py | 4 +- gocardless_pro/services/base_service.py | 11 +- .../billing_request_templates_service.py | 4 +- .../services/billing_requests_service.py | 4 +- gocardless_pro/services/blocks_service.py | 4 +- .../creditor_bank_accounts_service.py | 4 +- gocardless_pro/services/creditors_service.py | 4 +- .../customer_bank_accounts_service.py | 4 +- gocardless_pro/services/customers_service.py | 4 +- .../services/instalment_schedules_service.py | 8 +- .../services/mandate_imports_service.py | 4 +- gocardless_pro/services/mandates_service.py | 4 +- .../outbound_payment_imports_service.py | 4 +- .../services/outbound_payments_service.py | 4 +- .../services/payer_authorisations_service.py | 4 +- gocardless_pro/services/payments_service.py | 4 +- .../services/redirect_flows_service.py | 4 +- gocardless_pro/services/refunds_service.py | 4 +- .../services/scheme_identifiers_service.py | 4 +- .../services/subscriptions_service.py | 4 +- tests/idempotency_test.py | 103 ++++++++++++++++++ 22 files changed, 176 insertions(+), 22 deletions(-) create mode 100644 tests/idempotency_test.py diff --git a/gocardless_pro/services/bank_account_holder_verifications_service.py b/gocardless_pro/services/bank_account_holder_verifications_service.py index 9ff8198e..b29539a2 100644 --- a/gocardless_pro/services/bank_account_holder_verifications_service.py +++ b/gocardless_pro/services/bank_account_holder_verifications_service.py @@ -42,8 +42,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/bank_authorisations_service.py b/gocardless_pro/services/bank_authorisations_service.py index 1d084fd3..f1e332f6 100644 --- a/gocardless_pro/services/bank_authorisations_service.py +++ b/gocardless_pro/services/bank_authorisations_service.py @@ -39,8 +39,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/base_service.py b/gocardless_pro/services/base_service.py index 2c367cf4..cb33d82e 100644 --- a/gocardless_pro/services/base_service.py +++ b/gocardless_pro/services/base_service.py @@ -54,7 +54,16 @@ def _attempt_request(self, method, path, params, headers): def _inject_idempotency_key(self, headers): - headers = headers or {} + """Return the caller's headers with an idempotency key added, without touching theirs. + + Writing the key into the caller's own dict would leave it there after the call, so a + caller reusing one dict across several creates would send the first call's key every + time, and silently get back the resource the first call created. + + The copy is taken once per call, before the retry loop, so a retried request still + carries the same key. + """ + headers = dict(headers or {}) if 'Idempotency-Key' not in headers: headers['Idempotency-Key'] = str(uuid4()) diff --git a/gocardless_pro/services/billing_request_templates_service.py b/gocardless_pro/services/billing_request_templates_service.py index 56ff3f8b..27839fe9 100644 --- a/gocardless_pro/services/billing_request_templates_service.py +++ b/gocardless_pro/services/billing_request_templates_service.py @@ -90,8 +90,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/billing_requests_service.py b/gocardless_pro/services/billing_requests_service.py index 9ecf50f1..77da24e2 100644 --- a/gocardless_pro/services/billing_requests_service.py +++ b/gocardless_pro/services/billing_requests_service.py @@ -40,8 +40,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/blocks_service.py b/gocardless_pro/services/blocks_service.py index d25fd918..781a79b9 100644 --- a/gocardless_pro/services/blocks_service.py +++ b/gocardless_pro/services/blocks_service.py @@ -39,8 +39,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/creditor_bank_accounts_service.py b/gocardless_pro/services/creditor_bank_accounts_service.py index 6aad781f..65a8c066 100644 --- a/gocardless_pro/services/creditor_bank_accounts_service.py +++ b/gocardless_pro/services/creditor_bank_accounts_service.py @@ -39,8 +39,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/creditors_service.py b/gocardless_pro/services/creditors_service.py index 467dde6a..43dd3b12 100644 --- a/gocardless_pro/services/creditors_service.py +++ b/gocardless_pro/services/creditors_service.py @@ -39,8 +39,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/customer_bank_accounts_service.py b/gocardless_pro/services/customer_bank_accounts_service.py index f3872c08..c62ba8f4 100644 --- a/gocardless_pro/services/customer_bank_accounts_service.py +++ b/gocardless_pro/services/customer_bank_accounts_service.py @@ -54,8 +54,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/customers_service.py b/gocardless_pro/services/customers_service.py index a6f883ec..383661ef 100644 --- a/gocardless_pro/services/customers_service.py +++ b/gocardless_pro/services/customers_service.py @@ -39,8 +39,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/instalment_schedules_service.py b/gocardless_pro/services/instalment_schedules_service.py index a0c860ab..b9f0dae9 100644 --- a/gocardless_pro/services/instalment_schedules_service.py +++ b/gocardless_pro/services/instalment_schedules_service.py @@ -58,8 +58,10 @@ def create_with_dates(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) @@ -102,8 +104,10 @@ def create_with_schedule(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/mandate_imports_service.py b/gocardless_pro/services/mandate_imports_service.py index 53cb0cb5..7fb1d161 100644 --- a/gocardless_pro/services/mandate_imports_service.py +++ b/gocardless_pro/services/mandate_imports_service.py @@ -46,8 +46,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/mandates_service.py b/gocardless_pro/services/mandates_service.py index ac786f25..12e834d7 100644 --- a/gocardless_pro/services/mandates_service.py +++ b/gocardless_pro/services/mandates_service.py @@ -39,8 +39,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/outbound_payment_imports_service.py b/gocardless_pro/services/outbound_payment_imports_service.py index 15e2e056..4d5ad283 100644 --- a/gocardless_pro/services/outbound_payment_imports_service.py +++ b/gocardless_pro/services/outbound_payment_imports_service.py @@ -39,8 +39,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/outbound_payments_service.py b/gocardless_pro/services/outbound_payments_service.py index 5b13179d..2293cf9f 100644 --- a/gocardless_pro/services/outbound_payments_service.py +++ b/gocardless_pro/services/outbound_payments_service.py @@ -39,8 +39,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/payer_authorisations_service.py b/gocardless_pro/services/payer_authorisations_service.py index 226f520d..09db9986 100644 --- a/gocardless_pro/services/payer_authorisations_service.py +++ b/gocardless_pro/services/payer_authorisations_service.py @@ -68,8 +68,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/payments_service.py b/gocardless_pro/services/payments_service.py index 3553c465..6b599a7d 100644 --- a/gocardless_pro/services/payments_service.py +++ b/gocardless_pro/services/payments_service.py @@ -45,8 +45,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/redirect_flows_service.py b/gocardless_pro/services/redirect_flows_service.py index 992e3d5f..d2d8f269 100644 --- a/gocardless_pro/services/redirect_flows_service.py +++ b/gocardless_pro/services/redirect_flows_service.py @@ -40,8 +40,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/refunds_service.py b/gocardless_pro/services/refunds_service.py index e280811f..70a06326 100644 --- a/gocardless_pro/services/refunds_service.py +++ b/gocardless_pro/services/refunds_service.py @@ -51,8 +51,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/scheme_identifiers_service.py b/gocardless_pro/services/scheme_identifiers_service.py index 67425d3d..8c56d1c1 100644 --- a/gocardless_pro/services/scheme_identifiers_service.py +++ b/gocardless_pro/services/scheme_identifiers_service.py @@ -77,8 +77,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/gocardless_pro/services/subscriptions_service.py b/gocardless_pro/services/subscriptions_service.py index c66b1cd0..9fd8837d 100644 --- a/gocardless_pro/services/subscriptions_service.py +++ b/gocardless_pro/services/subscriptions_service.py @@ -39,8 +39,10 @@ def create(self,params=None, headers=None): except errors.IdempotentCreationConflictError as err: if self.raise_on_idempotency_conflict: raise err + # `params` is the create payload and is deliberately not forwarded: this is a + # GET for one resource, and requests would serialise the payload's keys into the + # query string. return self.get(identity=err.conflicting_resource_id, - params=params, headers=headers) return self._resource_for(response) diff --git a/tests/idempotency_test.py b/tests/idempotency_test.py new file mode 100644 index 00000000..1d8d4d43 --- /dev/null +++ b/tests/idempotency_test.py @@ -0,0 +1,103 @@ +# WARNING: Do not edit by hand, this file was generated by Crank: +# +# https://github.com/gocardless/crank +# + +import json + +import pytest +import requests +import responses + +from gocardless_pro import Client +from gocardless_pro import errors +from gocardless_pro.services.base_service import BaseService + +access_token = 'access-token-xyz' + + +@pytest.fixture +def client(): + return Client(access_token=access_token, base_url='http://example.com') + + +def idempotency_keys(): + return [call.request.headers['Idempotency-Key'] for call in responses.calls + if call.request.method == 'POST'] + + +def test_does_not_mutate_the_callers_headers(): + # The dict belongs to the caller. Leaving the generated key in it would make every later + # create reuse that key. + headers = {'Accept-Language': 'fr'} + + returned = BaseService(api_client=None)._inject_idempotency_key(headers) + + assert returned is not headers + assert headers == {'Accept-Language': 'fr'} + assert 'Idempotency-Key' in returned + + +def test_honours_an_explicit_idempotency_key(): + headers = {'Idempotency-Key': 'mine'} + + returned = BaseService(api_client=None)._inject_idempotency_key(headers) + + assert returned['Idempotency-Key'] == 'mine' + assert headers == {'Idempotency-Key': 'mine'} + + +@responses.activate +def test_reused_headers_dict_gets_a_fresh_key_for_each_create(client): + # Two independent creates sharing one non-empty headers dict, which is what a + # module-level custom-header template looks like. + responses.add(responses.POST, 'http://example.com/customers', + body=json.dumps({'customers': {'id': 'CU123'}}), status=201) + responses.add(responses.POST, 'http://example.com/customers', + body=json.dumps({'customers': {'id': 'CU456'}}), status=201) + shared_headers = {'Accept-Language': 'fr'} + + client.customers.create(params={'company_name': 'A'}, headers=shared_headers) + client.customers.create(params={'company_name': 'B'}, headers=shared_headers) + + first, second = idempotency_keys() + assert first != second + assert shared_headers == {'Accept-Language': 'fr'} + + +@responses.activate +def test_key_is_stable_across_network_retries(client): + # The key is generated once per call, so the API can recognise a retried request as the + # same operation rather than a new one. + responses.add(responses.POST, 'http://example.com/customers', + body=requests.exceptions.ConnectionError()) + responses.add(responses.POST, 'http://example.com/customers', + body=json.dumps({'customers': {'id': 'CU123'}}), status=201) + + client.customers.create(params={'company_name': 'A'}, + headers={'Accept-Language': 'fr'}) + + keys = idempotency_keys() + assert len(keys) == 2 + assert keys[0] == keys[1] + + +@responses.activate +def test_conflict_refetch_does_not_forward_the_create_payload(client): + # On a 409 the client refetches the conflicting resource. That is a GET for one resource, + # so the create payload must not be carried into its query string. + conflict = {'error': {'type': 'invalid_state', 'code': 409, 'message': 'Conflict', + 'errors': [{'reason': 'idempotent_creation_conflict', + 'message': 'A resource has already been created', + 'links': {'conflicting_resource_id': 'CU123'}}]}} + responses.add(responses.POST, 'http://example.com/customers', + body=json.dumps(conflict), status=409) + responses.add(responses.GET, 'http://example.com/customers/CU123', + body=json.dumps({'customers': {'id': 'CU123'}}), status=200) + + customer = client.customers.create(params={'company_name': 'A', 'metadata': {'x': 'y'}}, + headers={'Accept-Language': 'fr'}) + + assert customer.id == 'CU123' + refetch = [call.request for call in responses.calls if call.request.method == 'GET'][0] + assert refetch.url == 'http://example.com/customers/CU123'