diff --git a/Python/Pipfile b/Python/Pipfile index 196fc84..8de7546 100644 --- a/Python/Pipfile +++ b/Python/Pipfile @@ -4,7 +4,7 @@ url = "https://pypi.org/simple" verify_ssl = true [dev-packages] -mock = {version = ">=3.0.5", markers="python_version >= '2.7' and python_version < '3.3'"} +responses = ">=0.10.14" pytest = ">=2.8.0,<=3.10.1" pytest-cov = "*" @@ -15,6 +15,7 @@ numpy = "*" pandas = "*" pyarrow = "==0.15.1" requests = ">= 2.2.0" +backoff = "==1.10.0" [scripts] ci = "pytest" diff --git a/Python/Pipfile.lock b/Python/Pipfile.lock index 8578e4e..521d562 100644 --- a/Python/Pipfile.lock +++ b/Python/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "3e06a384b8caec85a92f04195a731f22675ff5c916db2410a9ba94785ffedc0d" + "sha256": "78e6599057be812575b33210d83b5f9d56fb2b1f00daba480eb7f16ca36c2075" }, "pipfile-spec": 6, "requires": {}, @@ -14,6 +14,14 @@ ] }, "default": { + "backoff": { + "hashes": [ + "sha256:5e73e2cbe780e1915a204799dba0a01896f45f4385e636bcca7a0614d879d0cd", + "sha256:b8fba021fac74055ac05eb7c7bfce4723aedde6cd0a504e5326bcb0bdd6d19a4" + ], + "index": "pypi", + "version": "==1.10.0" + }, "backports.functools-lru-cache": { "hashes": [ "sha256:0bada4c2f8a43d533e4ecb7a12214d9420e66eb206d54bf2d682581ca4b80848", @@ -23,10 +31,10 @@ }, "certifi": { "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" + "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", + "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" ], - "version": "==2019.11.28" + "version": "==2020.4.5.1" }, "chardet": { "hashes": [ @@ -45,12 +53,12 @@ }, "enum34": { "hashes": [ - "sha256:13ef9a1c478203252107f66c25b99b45b1865693ca1284aab40dafa7e1e7ac17", - "sha256:708aabfb3d5898f99674c390d360d59efdd08547019763622365f19e84a7fef4", - "sha256:98df1f1937840b7d8012fea7f0b36392a3e6fd8a2f429c48a3ff4b1aad907f3f" + "sha256:a98a201d6de3f2ab3db284e70a33b0f896fbf35f8086594e8c9e74b909058d53", + "sha256:c3858660960c984d6ab0ebad691265180da2b43f07e061c0f8dca9ef3cffd328", + "sha256:cce6a7477ed816bd2542d03d53db9f0db935dd013b70f336a95c73979289f248" ], "markers": "python_version < '3.4'", - "version": "==1.1.9" + "version": "==1.1.10" }, "futures": { "hashes": [ @@ -182,10 +190,10 @@ }, "pytz": { "hashes": [ - "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", - "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be" + "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", + "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" ], - "version": "==2019.3" + "version": "==2020.1" }, "requests": { "hashes": [ @@ -204,19 +212,19 @@ }, "urllib3": { "hashes": [ - "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", - "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" + "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", + "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], - "version": "==1.25.8" + "version": "==1.25.9" } }, "develop": { "atomicwrites": { "hashes": [ - "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", - "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" + "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197", + "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a" ], - "version": "==1.3.0" + "version": "==1.4.0" }, "attrs": { "hashes": [ @@ -225,6 +233,20 @@ ], "version": "==19.3.0" }, + "certifi": { + "hashes": [ + "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", + "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" + ], + "version": "==2020.4.5.1" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, "configparser": { "hashes": [ "sha256:254c1d9c79f60c45dfde850850883d5aaa7f19a23f13561243a050d5a7c3fe4c", @@ -241,65 +263,79 @@ "markers": "python_version < '3'", "version": "==0.6.0.post1" }, + "cookies": { + "hashes": [ + "sha256:15bee753002dff684987b8df8c235288eb8d45f8191ae056254812dfd42c81d3", + "sha256:d6b698788cae4cfa4e62ef8643a9ca332b79bd96cb314294b864ae8d7eb3ee8e" + ], + "markers": "python_version < '3.4'", + "version": "==2.2.1" + }, "coverage": { "hashes": [ - "sha256:15cf13a6896048d6d947bf7d222f36e4809ab926894beb748fc9caa14605d9c3", - "sha256:1daa3eceed220f9fdb80d5ff950dd95112cd27f70d004c7918ca6dfc6c47054c", - "sha256:1e44a022500d944d42f94df76727ba3fc0a5c0b672c358b61067abb88caee7a0", - "sha256:25dbf1110d70bab68a74b4b9d74f30e99b177cde3388e07cc7272f2168bd1477", - "sha256:3230d1003eec018ad4a472d254991e34241e0bbd513e97a29727c7c2f637bd2a", - "sha256:3dbb72eaeea5763676a1a1efd9b427a048c97c39ed92e13336e726117d0b72bf", - "sha256:5012d3b8d5a500834783689a5d2292fe06ec75dc86ee1ccdad04b6f5bf231691", - "sha256:51bc7710b13a2ae0c726f69756cf7ffd4362f4ac36546e243136187cfcc8aa73", - "sha256:527b4f316e6bf7755082a783726da20671a0cc388b786a64417780b90565b987", - "sha256:722e4557c8039aad9592c6a4213db75da08c2cd9945320220634f637251c3894", - "sha256:76e2057e8ffba5472fd28a3a010431fd9e928885ff480cb278877c6e9943cc2e", - "sha256:77afca04240c40450c331fa796b3eab6f1e15c5ecf8bf2b8bee9706cd5452fef", - "sha256:7afad9835e7a651d3551eab18cbc0fdb888f0a6136169fbef0662d9cdc9987cf", - "sha256:9bea19ac2f08672636350f203db89382121c9c2ade85d945953ef3c8cf9d2a68", - "sha256:a8b8ac7876bc3598e43e2603f772d2353d9931709345ad6c1149009fd1bc81b8", - "sha256:b0840b45187699affd4c6588286d429cd79a99d509fe3de0f209594669bb0954", - "sha256:b26aaf69713e5674efbde4d728fb7124e429c9466aeaf5f4a7e9e699b12c9fe2", - "sha256:b63dd43f455ba878e5e9f80ba4f748c0a2156dde6e0e6e690310e24d6e8caf40", - "sha256:be18f4ae5a9e46edae3f329de2191747966a34a3d93046dbdf897319923923bc", - "sha256:c312e57847db2526bc92b9bfa78266bfbaabac3fdcd751df4d062cd4c23e46dc", - "sha256:c60097190fe9dc2b329a0eb03393e2e0829156a589bd732e70794c0dd804258e", - "sha256:c62a2143e1313944bf4a5ab34fd3b4be15367a02e9478b0ce800cb510e3bbb9d", - "sha256:cc1109f54a14d940b8512ee9f1c3975c181bbb200306c6d8b87d93376538782f", - "sha256:cd60f507c125ac0ad83f05803063bed27e50fa903b9c2cfee3f8a6867ca600fc", - "sha256:d513cc3db248e566e07a0da99c230aca3556d9b09ed02f420664e2da97eac301", - "sha256:d649dc0bcace6fcdb446ae02b98798a856593b19b637c1b9af8edadf2b150bea", - "sha256:d7008a6796095a79544f4da1ee49418901961c97ca9e9d44904205ff7d6aa8cb", - "sha256:da93027835164b8223e8e5af2cf902a4c80ed93cb0909417234f4a9df3bcd9af", - "sha256:e69215621707119c6baf99bda014a45b999d37602cb7043d943c76a59b05bf52", - "sha256:ea9525e0fef2de9208250d6c5aeeee0138921057cd67fcef90fbed49c4d62d37", - "sha256:fca1669d464f0c9831fd10be2eef6b86f5ebd76c724d1e0706ebdff86bb4adf0" - ], - "version": "==5.0.3" + "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a", + "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355", + "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65", + "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7", + "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9", + "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1", + "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0", + "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55", + "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c", + "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6", + "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef", + "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019", + "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e", + "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0", + "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf", + "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24", + "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2", + "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c", + "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4", + "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0", + "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd", + "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04", + "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e", + "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730", + "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2", + "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768", + "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796", + "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7", + "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a", + "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489", + "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052" + ], + "version": "==5.1" }, "funcsigs": { "hashes": [ "sha256:330cc27ccbf7f1e992e69fef78261dc7c6569012cf397db8d3de0234e6c937ca", "sha256:a7bb0f2cf3a3fd1ab2732cb49eba4252c2af4240442415b4abce3b87022a8f50" ], - "markers": "python_version < '3.0'", + "markers": "python_version < '3.3'", "version": "==1.0.2" }, + "idna": { + "hashes": [ + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + ], + "version": "==2.8" + }, "importlib-metadata": { "hashes": [ - "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302", - "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b" + "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f", + "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e" ], "markers": "python_version < '3.8'", - "version": "==1.5.0" + "version": "==1.6.0" }, "mock": { "hashes": [ "sha256:83657d894c90d5681d62155c82bda9c1187827525880eda8ff5df4ec813437c3", "sha256:d157e52d4e5b938c550f39eb2fd15610db062441a9c2747d3dbfa9298211d0f8" ], - "index": "pypi", - "markers": "python_version >= '2.7' and python_version < '3.3'", + "markers": "python_version < '3.3'", "version": "==3.0.5" }, "more-itertools": { @@ -315,7 +351,7 @@ "sha256:0ec8205a157c80d7acc301c0b18fbd5d44fe655968f5d947b6ecef5290fc35db", "sha256:6cd9a47b597b37cc57de1c05e56fb1a1c9cc9fab04fe78c29acd090418529868" ], - "markers": "python_version < '3.6'", + "markers": "python_version < '3'", "version": "==2.3.5" }, "pluggy": { @@ -348,6 +384,22 @@ "index": "pypi", "version": "==2.8.1" }, + "requests": { + "hashes": [ + "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", + "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" + ], + "index": "pypi", + "version": "==2.22.0" + }, + "responses": { + "hashes": [ + "sha256:1a78bc010b20a5022a2c0cb76b8ee6dc1e34d887972615ebd725ab9a166a4960", + "sha256:3d596d0be06151330cb230a2d630717ab20f7a81f205019481e206eb5db79915" + ], + "index": "pypi", + "version": "==0.10.14" + }, "scandir": { "hashes": [ "sha256:2586c94e907d99617887daed6c1d102b5ca28f1085f90446554abf1faf73123e", @@ -372,6 +424,13 @@ ], "version": "==1.14.0" }, + "urllib3": { + "hashes": [ + "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", + "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" + ], + "version": "==1.25.9" + }, "zipp": { "hashes": [ "sha256:c70410551488251b0fee67b460fb9a536af8d6f9f008ad10ac51f615b6a521b1", diff --git a/Python/README.md b/Python/README.md index 315404a..eed32b0 100644 --- a/Python/README.md +++ b/Python/README.md @@ -56,3 +56,4 @@ You can use IBM Watson Studio with the following [demo notebook](https://datapla * `instance_crn`: SQL Query instance CRN identifier * `target_cos_url`: Optional default target URL. Don't use when you want to provide target URL in SQL statement text. * `client_info`: Optional string to identify your client application in IBM Cloud for PD reasons. + * `max_tries`: Optional integer to specify maximum attempts when dealing with request rate limit. Default value is `1`, which means it will through exception `RateLimitedException` when response status code is `429`. It will enable _exponential backoff_ when specifying any positive number greater than `1`. For instance, given `max_tries=5`, assuming it will get response status code `429` for 4 times until the 5th attempt will get response status code `201`, the wait time will be `2s`, `4s`, `8s` and `16s` for each attempts. diff --git a/Python/ibmcloudsql/SQLQuery.py b/Python/ibmcloudsql/SQLQuery.py index 2a308ff..e9b670b 100644 --- a/Python/ibmcloudsql/SQLQuery.py +++ b/Python/ibmcloudsql/SQLQuery.py @@ -20,6 +20,7 @@ import xml.etree.ElementTree as ET import sys import types +import backoff import requests from requests.exceptions import HTTPError import pandas as pd @@ -35,7 +36,7 @@ class RateLimitedException(Exception): pass class SQLQuery(): - def __init__(self, api_key, instance_crn, target_cos_url=None, client_info=''): + def __init__(self, api_key, instance_crn, target_cos_url=None, client_info='', max_tries=1): self.instance_crn = instance_crn self.target_cos = target_cos_url self.export_cos_url = target_cos_url @@ -44,6 +45,8 @@ def __init__(self, api_key, instance_crn, target_cos_url=None, client_info=''): else: self.user_agent = client_info + self.max_tries = max_tries + self.request_headers = {'Content-Type': 'application/json'} self.request_headers.update({'Accept': 'application/json'}) self.request_headers.update({'User-Agent': self.user_agent}) @@ -122,29 +125,14 @@ def logon(self, force=False): self.logged_on = True self.last_logon = datetime.now() - def submit_sql(self, sql_text, pagesize=None): - self.logon() - sqlData = {'statement': sql_text} - # If a valid pagesize is specified we need to append the proper PARTITIONED EVERY ROWS clause - if pagesize or pagesize==0: - if type(pagesize) == int and pagesize>0: - if self.target_cos: - sqlData["statement"] += " INTO {}".format(self.target_cos) - elif " INTO " not in sql_text.upper(): - raise SyntaxError("Neither resultset_target parameter nor \"INTO\" clause specified.") - elif " PARTITIONED " in sql_text.upper(): - raise SyntaxError("Must not use PARTITIONED clause when specifying pagesize parameter.") - sqlData["statement"] += " PARTITIONED EVERY {} ROWS".format(pagesize) - else: - raise ValueError('pagesize parameter ({}) is not valid.'.format(pagesize)) - elif self.target_cos: - sqlData.update({'resultset_target': self.target_cos}) + def _send_req(self, json_data): + '''send SQL data to API. return job id''' try: response = requests.post( "https://api.sql-query.cloud.ibm.com/v2/sql_jobs?instance_crn={}".format(self.instance_crn), headers=self.request_headers, - json=sqlData) + json=json_data) # Throw in case we hit the rate limit if (response.status_code == 429): @@ -161,6 +149,32 @@ def submit_sql(self, sql_text, pagesize=None): except HTTPError as e: raise SyntaxError("SQL submission failed: {}".format(response.json()['errors'][0]['message'])) + def submit_sql(self, sql_text, pagesize=None): + self.logon() + sqlData = {'statement': sql_text} + # If a valid pagesize is specified we need to append the proper PARTITIONED EVERY ROWS clause + if pagesize or pagesize==0: + if type(pagesize) == int and pagesize>0: + if self.target_cos: + sqlData["statement"] += " INTO {}".format(self.target_cos) + elif " INTO " not in sql_text.upper(): + raise SyntaxError("Neither resultset_target parameter nor \"INTO\" clause specified.") + elif " PARTITIONED " in sql_text.upper(): + raise SyntaxError("Must not use PARTITIONED clause when specifying pagesize parameter.") + sqlData["statement"] += " PARTITIONED EVERY {} ROWS".format(pagesize) + else: + raise ValueError('pagesize parameter ({}) is not valid.'.format(pagesize)) + elif self.target_cos: + sqlData.update({'resultset_target': self.target_cos}) + + intrumented_send = backoff.on_exception( + backoff.expo, + RateLimitedException, + max_tries=self.max_tries + )(self._send_req) + + return intrumented_send(sqlData) + def wait_for_job(self, jobId): self.logon() diff --git a/Python/setup.py b/Python/setup.py index 6b7fa65..ed49fd4 100644 --- a/Python/setup.py +++ b/Python/setup.py @@ -24,7 +24,7 @@ def readme(): version='0.3.14', python_requires='>=2.7, <4', install_requires=['pandas','requests','ibm-cos-sdk-core','ibm-cos-sdk','numpy', - 'pyarrow==0.15.1'], + 'pyarrow==0.15.1', 'backoff==1.10.0'], description='Python client for interacting with IBM Cloud SQL Query service', url='https://github.com/IBM-Cloud/sql-query-clients', author='IBM Corp.', diff --git a/Python/tests/test_sqlquery.py b/Python/tests/test_sqlquery.py index e67c915..5ff0492 100644 --- a/Python/tests/test_sqlquery.py +++ b/Python/tests/test_sqlquery.py @@ -1,9 +1,51 @@ -from ibmcloudsql import SQLQuery +from datetime import datetime -def test_init(): - sqlClient = SQLQuery('mock-api-key', 'mock-crn', client_info='ibmcloudsql test') +import responses +import pytest - assert(sqlClient.request_headers == {'Content-Type': 'application/json', +from ibmcloudsql import SQLQuery, RateLimitedException + + +@pytest.fixture +def sqlquery_client(): + sql_client = SQLQuery('mock-api-key', 'mock-crn', client_info='ibmcloudsql test') + + # TODO mock method .logon() instead of hacking + # disable authentication step for 300s + sql_client.logged_on = True + sql_client.last_logon = datetime.now() + + return sql_client + +def test_init(sqlquery_client): + assert(sqlquery_client.request_headers == {'Content-Type': 'application/json', 'Accept': 'application/json', - 'User-Agent': sqlClient.user_agent - }) \ No newline at end of file + 'User-Agent': sqlquery_client.user_agent + }) + +@responses.activate +def test_submit_sql_no_retry(sqlquery_client): + '''expect exception when getting status code 429''' + mock_error_message = 'too many requests' + responses.add(responses.POST, 'https://api.sql-query.cloud.ibm.com/v2/sql_jobs', + json={'errors': [{'message': mock_error_message}]}, status=429) + + with pytest.raises(RateLimitedException, match=mock_error_message) as exc_info: + sqlquery_client.submit_sql('VALUES (1)') + +@responses.activate +def test_submit_sql_w_retry(sqlquery_client): + '''retry when getting status code 429''' + mock_error_message = 'too many requests' + mock_job_id = 'fake-digest' + + sqlquery_client.max_tries = 3 + + responses.add(responses.POST, 'https://api.sql-query.cloud.ibm.com/v2/sql_jobs', + json={'errors': [{'message': mock_error_message}]}, status=429) + responses.add(responses.POST, 'https://api.sql-query.cloud.ibm.com/v2/sql_jobs', + json={'errors': [{'message': mock_error_message}]}, status=429) + responses.add(responses.POST, 'https://api.sql-query.cloud.ibm.com/v2/sql_jobs', + json={'job_id': mock_job_id}, status=201) + + assert sqlquery_client.submit_sql('VALUES (1)') == mock_job_id \ No newline at end of file