diff --git a/tests/integration/test_catalog.py b/tests/integration/test_catalog.py index bc6a2d77..cc88bd4c 100644 --- a/tests/integration/test_catalog.py +++ b/tests/integration/test_catalog.py @@ -1,6 +1,8 @@ import time import uuid +from functools import wraps +from square.core.api_error import ApiError from square.core.request_options import RequestOptions from square.types.catalog_item import CatalogItem from square.types.catalog_item_variation import CatalogItemVariation @@ -20,55 +22,99 @@ MAX_TIMEOUT = 120 +def retry_on_rate_limit(max_retries=5, base_delay=2): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + for attempt in range(max_retries): + try: + return func(*args, **kwargs) + except ApiError as e: + if e.status_code == 429 and attempt < max_retries - 1: + delay = base_delay * (2 ** attempt) # exponential backoff + print(f"Rate limited. Retrying in {delay} seconds...") + time.sleep(delay) + continue + raise + return None + return wrapper + return decorator + + +@retry_on_rate_limit() def test_upload_catalog_image(): # Wait to kick off the first test to avoid being rate limited. time.sleep(3) client = helpers.test_client() - # Setup: Create a catalog object to associate the image with - catalog_object = helpers.create_test_catalog_item( - helpers.CreateCatalogItemOptions() - ) - create_catalog_resp = client.catalog.batch_upsert( - idempotency_key=str(uuid.uuid4()), - batches=[CatalogObjectBatch(objects=[catalog_object])], - ) - - objects = create_catalog_resp.objects - assert objects is not None - assert 1 == len(objects) - created_catalog_object = objects[0] - assert isinstance(created_catalog_object, CatalogObjectItem) - assert created_catalog_object.id is not None - - # Create a new catalog image - image_name = "Test Image " + str(uuid.uuid4()) - create_catalog_image_resp = client.catalog.images.create( - image_file=helpers.get_test_file(), - request={ - "idempotency_key": str(uuid.uuid4()), - "image": { - "type": "IMAGE", - "id": helpers.new_test_square_id(), - "image_data": {"name": image_name}, + try: + # Setup: Create a catalog object to associate the image with + catalog_object = helpers.create_test_catalog_item( + helpers.CreateCatalogItemOptions() + ) + create_catalog_resp = client.catalog.batch_upsert( + idempotency_key=str(uuid.uuid4()), + batches=[CatalogObjectBatch(objects=[catalog_object])], + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), + ) + + time.sleep(2) # Add delay after creation + + objects = create_catalog_resp.objects + assert objects is not None + assert 1 == len(objects) + created_catalog_object = objects[0] + assert isinstance(created_catalog_object, CatalogObjectItem) + assert created_catalog_object.id is not None + + # Create a new catalog image + image_name = "Test Image " + str(uuid.uuid4()) + create_catalog_image_resp = client.catalog.images.create( + image_file=helpers.get_test_file(), + request={ + "idempotency_key": str(uuid.uuid4()), + "image": { + "type": "IMAGE", + "id": helpers.new_test_square_id(), + "image_data": {"name": image_name}, + }, + "object_id": created_catalog_object.id, }, - "object_id": created_catalog_object.id, - }, - request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), - ) - image = create_catalog_image_resp.image - assert image is not None - assert isinstance(image, CatalogObjectImage) - - # Cleanup - client.catalog.batch_delete( - object_ids=[created_catalog_object.id, image.id], - request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), - ) - - + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), + ) + + time.sleep(2) # Add delay after image creation + + image = create_catalog_image_resp.image + assert image is not None + assert isinstance(image, CatalogObjectImage) + + # Add retry logic for cleanup + for attempt in range(MAX_RETRIES): + try: + time.sleep(2) # Add delay before cleanup attempt + # Cleanup + client.catalog.batch_delete( + object_ids=[created_catalog_object.id, image.id], + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), + ) + break + except ApiError as e: + if e.status_code == 429 and attempt < MAX_RETRIES - 1: + delay = 2 * (2 ** attempt) + print(f"Cleanup rate limited. Retrying in {delay} seconds...") + time.sleep(delay) + continue + raise + except Exception as e: + print(f"Error in test_upload_catalog_image: {str(e)}") + raise + + +@retry_on_rate_limit() def test_upsert_catalog_object(): + time.sleep(2) # Add initial delay client = helpers.test_client() coffee_variation_opts = helpers.CreateCatalogItemVariationOptions() @@ -106,7 +152,9 @@ def test_upsert_catalog_object(): assert "Colombian Fair Trade" == item_variation_data.name +@retry_on_rate_limit() def test_catalog_info(): + time.sleep(2) # Add initial delay client = helpers.test_client() response = client.catalog.search( limit=1, request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT) @@ -116,7 +164,9 @@ def test_catalog_info(): assert len(response.objects) > 0 +@retry_on_rate_limit() def test_search_catalog_items(): + time.sleep(2) # Add initial delay client = helpers.test_client() response = client.catalog.search_items( limit=1, request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT) @@ -124,6 +174,7 @@ def test_search_catalog_items(): assert response is not None +@retry_on_rate_limit() def test_batch_upsert_catalog_objects(): # Wait to kick off this test to avoid being rate limited. time.sleep(3) @@ -187,8 +238,11 @@ def test_batch_upsert_catalog_objects(): ] } ], + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), ) + time.sleep(2) # Add delay after batch upsert + objects = response.objects assert objects is not None assert 2 == len(objects) @@ -220,6 +274,8 @@ def test_batch_upsert_catalog_objects(): assert len(catalog_modifier_list_ids) > 0 catalog_modifier_list_id = catalog_modifier_list_ids[0] + time.sleep(2) # Add delay before batch get + response = client.catalog.batch_get( object_ids=[catalog_modifier_id, catalog_modifier_list_id, catalog_tax_id], request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), @@ -235,15 +291,21 @@ def test_batch_upsert_catalog_objects(): catalog_tax_id, } + time.sleep(2) # Add delay before catalog item creation + catalog_item = helpers.create_test_catalog_item(helpers.CreateCatalogItemOptions()) catalog_response = client.catalog.object.upsert( - idempotency_key=str(uuid.uuid4()), object=catalog_item + idempotency_key=str(uuid.uuid4()), + object=catalog_item, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), ) catalog_object = catalog_response.catalog_object assert catalog_object is not None assert isinstance(catalog_object, CatalogObjectItem) catalog_object_id = catalog_object.id + time.sleep(2) # Add delay before update taxes + response = client.catalog.update_item_taxes( item_ids=[catalog_object_id], taxes_to_enable=[catalog_tax_id], @@ -253,6 +315,8 @@ def test_batch_upsert_catalog_objects(): assert response.updated_at is not None assert response.errors is None + time.sleep(2) # Add delay before update modifier lists + response = client.catalog.update_item_modifier_lists( item_ids=[catalog_object_id], modifier_lists_to_enable=[catalog_modifier_list_id], @@ -263,18 +327,27 @@ def test_batch_upsert_catalog_objects(): assert response.errors is None +@retry_on_rate_limit() def test_delete_catalog_object(): + time.sleep(2) # Add initial delay client = helpers.test_client() catalog_item = helpers.create_test_catalog_item(helpers.CreateCatalogItemOptions()) catalog_response = client.catalog.object.upsert( - idempotency_key=str(uuid.uuid4()), object=catalog_item + idempotency_key=str(uuid.uuid4()), + object=catalog_item, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), ) catalog_object = catalog_response.catalog_object assert catalog_object is not None assert isinstance(catalog_object, CatalogObjectItem) catalog_object_id = catalog_object.id - response = client.catalog.object.delete(object_id=catalog_object_id) + time.sleep(2) # Add delay before delete - assert response is not None + response = client.catalog.object.delete( + object_id=catalog_object_id, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), + ) + + assert response is not None \ No newline at end of file diff --git a/tests/integration/test_inventory.py b/tests/integration/test_inventory.py index cd04ca10..65791771 100644 --- a/tests/integration/test_inventory.py +++ b/tests/integration/test_inventory.py @@ -1,6 +1,9 @@ import uuid +import time from datetime import datetime, timedelta +from typing import Optional +from square.core.api_error import ApiError from square.requests.catalog_item import CatalogItemParams from square.requests.catalog_item_variation import CatalogItemVariationParams from square.requests.catalog_object_item_variation import ( @@ -14,6 +17,27 @@ from . import helpers +def retry_on_rate_limit(func): + """Decorator to retry functions on rate limit errors""" + def wrapper(*args, **kwargs): + max_retries = 5 + base_delay = 2 # seconds + + for attempt in range(max_retries): + try: + return func(*args, **kwargs) + except ApiError as e: + if e.status_code == 429 and attempt < max_retries - 1: + delay = base_delay * (2 ** attempt) # exponential backoff + print(f"Rate limited. Retrying in {delay} seconds...") + time.sleep(delay) + continue + raise + return None + return wrapper + + +@retry_on_rate_limit def create_catalog_item_variation() -> str: client = helpers.test_client() @@ -67,7 +91,8 @@ def create_catalog_item_variation() -> str: return item_variation_ids[0] -def create_initial_adjustment(item_variation_id: str): +@retry_on_rate_limit +def create_initial_adjustment(item_variation_id: str) -> Optional[str]: """ Create an initial inventory adjustment and return the physical count ID """ @@ -91,6 +116,9 @@ def create_initial_adjustment(item_variation_id: str): ], ) + # Add delay after the first operation + time.sleep(2) + changes = response.changes assert changes is not None assert len(changes) > 0 @@ -115,6 +143,9 @@ def create_initial_adjustment(item_variation_id: str): ], ) + # Add delay after the second operation + time.sleep(2) + physical_changes_response = client.inventory.batch_get_changes( types=["PHYSICAL_COUNT"], catalog_object_ids=[item_variation_id], @@ -133,7 +164,9 @@ def create_initial_adjustment(item_variation_id: str): def test_batch_change_inventory(): client = helpers.test_client() item_variation_id = create_catalog_item_variation() + time.sleep(2) # Add delay after catalog operation create_initial_adjustment(item_variation_id) + time.sleep(2) # Add delay after adjustment response = client.inventory.batch_create_changes( idempotency_key=str(uuid.uuid4()), @@ -163,7 +196,9 @@ def test_batch_change_inventory(): def test_batch_retrieve_inventory_changes(): client = helpers.test_client() item_variation_id = create_catalog_item_variation() + time.sleep(2) # Add delay after catalog operation create_initial_adjustment(item_variation_id) + time.sleep(2) # Add delay after adjustment response = client.inventory.batch_get_changes( catalog_object_ids=[item_variation_id] @@ -175,7 +210,9 @@ def test_batch_retrieve_inventory_changes(): def test_batch_retrieve_inventory_counts(): client = helpers.test_client() item_variation_id = create_catalog_item_variation() + time.sleep(2) # Add delay after catalog operation create_initial_adjustment(item_variation_id) + time.sleep(2) # Add delay after adjustment response = client.inventory.batch_get_counts(catalog_object_ids=[item_variation_id]) assert response.items is not None @@ -185,7 +222,9 @@ def test_batch_retrieve_inventory_counts(): def test_retrieve_inventory_changes(): client = helpers.test_client() item_variation_id = create_catalog_item_variation() + time.sleep(2) # Add delay after catalog operation create_initial_adjustment(item_variation_id) + time.sleep(2) # Add delay after adjustment response = client.inventory.get(catalog_object_id=item_variation_id) assert response.items is not None @@ -195,7 +234,9 @@ def test_retrieve_inventory_changes(): def test_retrieve_inventory_counts(): client = helpers.test_client() item_variation_id = create_catalog_item_variation() + time.sleep(2) # Add delay after catalog operation physical_count_id = create_initial_adjustment(item_variation_id) + time.sleep(2) # Add delay after adjustment response = client.inventory.get_physical_count(physical_count_id=physical_count_id) assert response.count is not None @@ -204,7 +245,9 @@ def test_retrieve_inventory_counts(): def test_retrieve_inventory_adjustments(): client = helpers.test_client() item_variation_id = create_catalog_item_variation() + time.sleep(2) # Add delay after catalog operation create_initial_adjustment(item_variation_id) + time.sleep(2) # Add delay after adjustment response = client.inventory.batch_create_changes( idempotency_key=str(uuid.uuid4()), @@ -230,6 +273,9 @@ def test_retrieve_inventory_adjustments(): assert isinstance(changes[0].adjustment, InventoryAdjustment) assert changes[0].adjustment.id is not None adjustment_id = changes[0].adjustment.id + + time.sleep(2) # Add delay before retrieve + retrieve_response = client.inventory.get_adjustment(adjustment_id=adjustment_id) retrieve_adjustment = retrieve_response.adjustment assert retrieve_adjustment is not None @@ -238,4 +284,4 @@ def test_retrieve_inventory_adjustments(): assert retrieve_adjustment_id is not None assert retrieve_response.adjustment is not None - assert adjustment_id == retrieve_adjustment_id + assert adjustment_id == retrieve_adjustment_id \ No newline at end of file diff --git a/tests/integration/test_labor.py b/tests/integration/test_labor.py index d2c91340..c7453a80 100644 --- a/tests/integration/test_labor.py +++ b/tests/integration/test_labor.py @@ -1,7 +1,10 @@ import time import uuid from datetime import datetime, timedelta +from functools import wraps +from square.core.api_error import ApiError +from square.core.request_options import RequestOptions from square.types.break_type import BreakType from square.types.money import Money from square.types.shift import Shift @@ -10,13 +13,37 @@ from . import helpers - +MAX_TIMEOUT = 120 +MAX_RETRIES = 5 + + +def retry_with_backoff(max_retries=5, base_delay=2): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + for attempt in range(max_retries): + try: + return func(*args, **kwargs) + except (ApiError, Exception) as e: + if attempt < max_retries - 1: + delay = base_delay * (2 ** attempt) # exponential backoff + print(f"Request failed. Retrying in {delay} seconds... Error: {str(e)}") + time.sleep(delay) + continue + raise + return None + return wrapper + return decorator + + +@retry_with_backoff() def create_team_member() -> str: client = helpers.test_client() team_response = client.team_members.create( idempotency_key=str(uuid.uuid4()), team_member={"given_name": "Sherlock", "family_name": "Holmes"}, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), ) team_member = team_response.team_member assert team_member is not None @@ -25,6 +52,7 @@ def create_team_member() -> str: return team_member.id +@retry_with_backoff() def create_break_type() -> str: client = helpers.test_client() @@ -36,6 +64,7 @@ def create_break_type() -> str: "is_paid": True, }, idempotency_key=str(uuid.uuid4()), + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), ) break_type = break_response.break_type assert break_type is not None @@ -47,12 +76,17 @@ def create_break_type() -> str: def delete_break_type(break_type_id: str): client = helpers.test_client() try: - client.labor.break_types.delete(id=break_type_id) + client.labor.break_types.delete( + id=break_type_id, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), + ) except Exception as e: # test may have already deleted the break + print(f"Error deleting break type: {str(e)}") pass +@retry_with_backoff() def create_shift(team_member_id: str) -> str: client = helpers.test_client() @@ -63,6 +97,7 @@ def create_shift(team_member_id: str) -> str: "team_member_id": team_member_id, }, idempotency_key=str(uuid.uuid4()), + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), ) shift = shift_response.shift assert shift is not None @@ -74,16 +109,22 @@ def create_shift(team_member_id: str) -> str: def delete_shift(shift_id: str): client = helpers.test_client() try: - client.labor.shifts.delete(id=shift_id) + client.labor.shifts.delete( + id=shift_id, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), + ) except Exception as e: # test may have already deleted the shift + print(f"Error deleting shift: {str(e)}") pass +@retry_with_backoff() def get_first_break_type_id() -> str: client = helpers.test_client() response = client.labor.break_types.list( - location_id=helpers.get_default_location_id(client) + location_id=helpers.get_default_location_id(client), + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), ) break_types = response.items if break_types is not None and len(break_types) > 0: @@ -91,32 +132,44 @@ def get_first_break_type_id() -> str: raise Exception("No break types found") +@retry_with_backoff() def test_get_break_type(): # Wait to kick off the first test to avoid being rate limited. time.sleep(3) client = helpers.test_client() team_member_id = create_team_member() + time.sleep(2) # Add delay between operations break_type_id = create_break_type() + time.sleep(2) # Add delay between operations shift_id = create_shift(team_member_id) - response = client.labor.break_types.get(id=break_type_id) + response = client.labor.break_types.get( + id=break_type_id, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), + ) assert response.break_type is not None assert isinstance(response.break_type, BreakType) assert break_type_id == response.break_type.id - list_response = client.labor.break_types.list() + list_response = client.labor.break_types.list( + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), + ) assert list_response.items is not None assert len(list_response.items) > 0 + time.sleep(2) # Add delay before cleanup delete_break_type(break_type_id) delete_shift(shift_id) +@retry_with_backoff() def test_update_break_type(): client = helpers.test_client() team_member_id = create_team_member() + time.sleep(2) # Add delay between operations break_type_id = create_break_type() + time.sleep(2) # Add delay between operations shift_id = create_shift(team_member_id) response = client.labor.break_types.update( @@ -127,49 +180,68 @@ def test_update_break_type(): "expected_duration": "PT1H0M0S", "is_paid": True, }, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), ) assert response.break_type is not None assert isinstance(response.break_type, BreakType) assert break_type_id == response.break_type.id assert "PT1H" == response.break_type.expected_duration + time.sleep(2) # Add delay before cleanup delete_break_type(break_type_id) delete_shift(shift_id) +@retry_with_backoff() def test_search_shifts(): client = helpers.test_client() team_member_id = create_team_member() + time.sleep(2) # Add delay between operations break_type_id = create_break_type() + time.sleep(2) # Add delay between operations shift_id = create_shift(team_member_id) - response = client.labor.shifts.search(limit=1) + response = client.labor.shifts.search( + limit=1, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), + ) assert response.shifts is not None assert len(response.shifts) > 0 + time.sleep(2) # Add delay before cleanup delete_break_type(break_type_id) delete_shift(shift_id) +@retry_with_backoff() def test_get_shift(): client = helpers.test_client() team_member_id = create_team_member() + time.sleep(2) # Add delay between operations break_type_id = create_break_type() + time.sleep(2) # Add delay between operations shift_id = create_shift(team_member_id) - response = client.labor.shifts.get(id=shift_id) + response = client.labor.shifts.get( + id=shift_id, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), + ) assert response.shift is not None assert isinstance(response.shift, Shift) assert shift_id == response.shift.id + time.sleep(2) # Add delay before cleanup delete_break_type(break_type_id) delete_shift(shift_id) +@retry_with_backoff() def test_update_shift(): client = helpers.test_client() team_member_id = create_team_member() + time.sleep(2) # Add delay between operations break_type_id = create_break_type() + time.sleep(2) # Add delay between operations shift_id = create_shift(team_member_id) response = client.labor.shifts.update( @@ -185,6 +257,7 @@ def test_update_shift(): "hourly_rate": {"amount": 2500, "currency": "USD"}, }, }, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), ) shift = response.shift assert shift is not None @@ -196,22 +269,27 @@ def test_update_shift(): assert 2500 == shift.wage.hourly_rate.amount assert "USD" == shift.wage.hourly_rate.currency + time.sleep(2) # Add delay before cleanup delete_break_type(break_type_id) delete_shift(shift_id) +@retry_with_backoff() def test_delete_shift(): client = helpers.test_client() team_member_response = client.team_members.create( idempotency_key=str(uuid.uuid4()), team_member={"given_name": "Sherlock", "family_name": "Holmes"}, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), ) assert team_member_response.team_member is not None assert isinstance(team_member_response.team_member, TeamMember) assert team_member_response.team_member.id is not None + time.sleep(2) # Add delay between operations + shift_response = client.labor.shifts.create( shift={ "location_id": helpers.get_default_location_id(client), @@ -219,16 +297,24 @@ def test_delete_shift(): "team_member_id": team_member_response.team_member.id, }, idempotency_key=str(uuid.uuid4()), + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), ) assert shift_response.shift is not None assert isinstance(shift_response.shift, Shift) assert shift_response.shift.id is not None shift_id = shift_response.shift.id - response = client.labor.shifts.delete(id=shift_id) + + time.sleep(2) # Add delay before delete + + response = client.labor.shifts.delete( + id=shift_id, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), + ) assert response is not None +@retry_with_backoff() def test_delete_break_type(): client = helpers.test_client() @@ -240,6 +326,7 @@ def test_delete_break_type(): "is_paid": True, }, idempotency_key=str(uuid.uuid4()), + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), ) break_type = break_response.break_type @@ -248,12 +335,20 @@ def test_delete_break_type(): assert break_type.id is not None break_id = break_type.id - response = client.labor.break_types.delete(id=break_id) + time.sleep(2) # Add delay before delete + + response = client.labor.break_types.delete( + id=break_id, + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), + ) assert response is not None +@retry_with_backoff() def test_list_workweek_configs(): client = helpers.test_client() - response = client.labor.workweek_configs.list() + response = client.labor.workweek_configs.list( + request_options=RequestOptions(timeout_in_seconds=MAX_TIMEOUT), + ) assert response.items is not None - assert len(response.items) > 0 + assert len(response.items) > 0 \ No newline at end of file