diff --git a/.gitignore b/.gitignore index 3503a4d..aa9285a 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ web_modules/ npm/ openapi/ +ARCHITECTURE.md +.claude +deno.lock diff --git a/deno.lock b/deno.lock deleted file mode 100644 index c482e5f..0000000 --- a/deno.lock +++ /dev/null @@ -1,127 +0,0 @@ -{ - "version": "2", - "remote": { - "https://crux.land/api/get/2KNRVU.ts": "6a77d55844aba78d01520c5ff0b2f0af7f24cc1716a0de8b3bb6bd918c47b5ba", - "https://deno.land/std@0.140.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", - "https://deno.land/std@0.140.0/_util/os.ts": "3b4c6e27febd119d36a416d7a97bd3b0251b77c88942c8f16ee5953ea13e2e49", - "https://deno.land/std@0.140.0/bytes/bytes_list.ts": "67eb118e0b7891d2f389dad4add35856f4ad5faab46318ff99653456c23b025d", - "https://deno.land/std@0.140.0/bytes/equals.ts": "fc16dff2090cced02497f16483de123dfa91e591029f985029193dfaa9d894c9", - "https://deno.land/std@0.140.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf", - "https://deno.land/std@0.140.0/fmt/colors.ts": "30455035d6d728394781c10755351742dd731e3db6771b1843f9b9e490104d37", - "https://deno.land/std@0.140.0/fs/_util.ts": "0fb24eb4bfebc2c194fb1afdb42b9c3dda12e368f43e8f2321f84fc77d42cb0f", - "https://deno.land/std@0.140.0/fs/ensure_dir.ts": "9dc109c27df4098b9fc12d949612ae5c9c7169507660dcf9ad90631833209d9d", - "https://deno.land/std@0.140.0/hash/sha256.ts": "803846c7a5a8a5a97f31defeb37d72f519086c880837129934f5d6f72102a8e8", - "https://deno.land/std@0.140.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b", - "https://deno.land/std@0.140.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", - "https://deno.land/std@0.140.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", - "https://deno.land/std@0.140.0/path/_util.ts": "c1e9686d0164e29f7d880b2158971d805b6e0efc3110d0b3e24e4b8af2190d2b", - "https://deno.land/std@0.140.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", - "https://deno.land/std@0.140.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee", - "https://deno.land/std@0.140.0/path/mod.ts": "d3e68d0abb393fb0bf94a6d07c46ec31dc755b544b13144dee931d8d5f06a52d", - "https://deno.land/std@0.140.0/path/posix.ts": "293cdaec3ecccec0a9cc2b534302dfe308adb6f10861fa183275d6695faace44", - "https://deno.land/std@0.140.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", - "https://deno.land/std@0.140.0/path/win32.ts": "31811536855e19ba37a999cd8d1b62078235548d67902ece4aa6b814596dd757", - "https://deno.land/std@0.140.0/streams/conversion.ts": "712585bfa0172a97fb68dd46e784ae8ad59d11b88079d6a4ab098ff42e697d21", - "https://deno.land/std@0.181.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", - "https://deno.land/std@0.181.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", - "https://deno.land/std@0.181.0/fs/_util.ts": "65381f341af1ff7f40198cee15c20f59951ac26e51ddc651c5293e24f9ce6f32", - "https://deno.land/std@0.181.0/fs/ensure_dir.ts": "dc64c4c75c64721d4e3fb681f1382f803ff3d2868f08563ff923fdd20d071c40", - "https://deno.land/std@0.181.0/fs/expand_glob.ts": "e4f56259a0a70fe23f05215b00de3ac5e6ba46646ab2a06ebbe9b010f81c972a", - "https://deno.land/std@0.181.0/fs/walk.ts": "ea95ffa6500c1eda6b365be488c056edc7c883a1db41ef46ec3bf057b1c0fe32", - "https://deno.land/std@0.181.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", - "https://deno.land/std@0.181.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", - "https://deno.land/std@0.181.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", - "https://deno.land/std@0.181.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", - "https://deno.land/std@0.181.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", - "https://deno.land/std@0.181.0/path/mod.ts": "bf718f19a4fdd545aee1b06409ca0805bd1b68ecf876605ce632e932fe54510c", - "https://deno.land/std@0.181.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", - "https://deno.land/std@0.181.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", - "https://deno.land/std@0.181.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", - "https://deno.land/std@0.182.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", - "https://deno.land/std@0.182.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", - "https://deno.land/std@0.182.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", - "https://deno.land/std@0.182.0/fs/_util.ts": "65381f341af1ff7f40198cee15c20f59951ac26e51ddc651c5293e24f9ce6f32", - "https://deno.land/std@0.182.0/fs/empty_dir.ts": "c3d2da4c7352fab1cf144a1ecfef58090769e8af633678e0f3fabaef98594688", - "https://deno.land/std@0.182.0/fs/expand_glob.ts": "e4f56259a0a70fe23f05215b00de3ac5e6ba46646ab2a06ebbe9b010f81c972a", - "https://deno.land/std@0.182.0/fs/walk.ts": "920be35a7376db6c0b5b1caf1486fb962925e38c9825f90367f8f26b5e5d0897", - "https://deno.land/std@0.182.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", - "https://deno.land/std@0.182.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", - "https://deno.land/std@0.182.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", - "https://deno.land/std@0.182.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", - "https://deno.land/std@0.182.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", - "https://deno.land/std@0.182.0/path/mod.ts": "bf718f19a4fdd545aee1b06409ca0805bd1b68ecf876605ce632e932fe54510c", - "https://deno.land/std@0.182.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", - "https://deno.land/std@0.182.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", - "https://deno.land/std@0.182.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", - "https://deno.land/std@0.196.0/_util/diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", - "https://deno.land/std@0.196.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", - "https://deno.land/std@0.196.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", - "https://deno.land/std@0.196.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", - "https://deno.land/std@0.196.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", - "https://deno.land/std@0.196.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", - "https://deno.land/std@0.196.0/assert/assert_equals.ts": "a0ee60574e437bcab2dcb79af9d48dc88845f8fd559468d9c21b15fd638ef943", - "https://deno.land/std@0.196.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", - "https://deno.land/std@0.196.0/assert/assert_false.ts": "a9962749f4bf5844e3fa494257f1de73d69e4fe0e82c34d0099287552163a2dc", - "https://deno.land/std@0.196.0/assert/assert_instance_of.ts": "09fd297352a5b5bbb16da2b5e1a0d8c6c44da5447772648622dcc7df7af1ddb8", - "https://deno.land/std@0.196.0/assert/assert_is_error.ts": "b4eae4e5d182272efc172bf28e2e30b86bb1650cd88aea059e5d2586d4160fb9", - "https://deno.land/std@0.196.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", - "https://deno.land/std@0.196.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", - "https://deno.land/std@0.196.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", - "https://deno.land/std@0.196.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", - "https://deno.land/std@0.196.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", - "https://deno.land/std@0.196.0/assert/assert_object_match.ts": "27439c4f41dce099317566144299468ca822f556f1cc697f4dc8ed61fe9fee4c", - "https://deno.land/std@0.196.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", - "https://deno.land/std@0.196.0/assert/assert_strict_equals.ts": "5cf29b38b3f8dece95287325723272aa04e04dbf158d886d662fa594fddc9ed3", - "https://deno.land/std@0.196.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", - "https://deno.land/std@0.196.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", - "https://deno.land/std@0.196.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", - "https://deno.land/std@0.196.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", - "https://deno.land/std@0.196.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", - "https://deno.land/std@0.196.0/assert/mod.ts": "08d55a652c22c5da0215054b21085cec25a5da47ce4a6f9de7d9ad36df35bdee", - "https://deno.land/std@0.196.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", - "https://deno.land/std@0.196.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", - "https://deno.land/std@0.196.0/fmt/colors.ts": "a7eecffdf3d1d54db890723b303847b6e0a1ab4b528ba6958b8f2e754cf1b3bc", - "https://deno.land/x/code_block_writer@12.0.0/mod.ts": "2c3448060e47c9d08604c8f40dee34343f553f33edcdfebbf648442be33205e5", - "https://deno.land/x/code_block_writer@12.0.0/utils/string_utils.ts": "60cb4ec8bd335bf241ef785ccec51e809d576ff8e8d29da43d2273b69ce2a6ff", - "https://deno.land/x/deno_cache@0.4.1/auth_tokens.ts": "5fee7e9155e78cedf3f6ff3efacffdb76ac1a76c86978658d9066d4fb0f7326e", - "https://deno.land/x/deno_cache@0.4.1/cache.ts": "51f72f4299411193d780faac8c09d4e8cbee951f541121ef75fcc0e94e64c195", - "https://deno.land/x/deno_cache@0.4.1/deno_dir.ts": "f2a9044ce8c7fe1109004cda6be96bf98b08f478ce77e7a07f866eff1bdd933f", - "https://deno.land/x/deno_cache@0.4.1/deps.ts": "8974097d6c17e65d9a82d39377ae8af7d94d74c25c0cbb5855d2920e063f2343", - "https://deno.land/x/deno_cache@0.4.1/dirs.ts": "d2fa473ef490a74f2dcb5abb4b9ab92a48d2b5b6320875df2dee64851fa64aa9", - "https://deno.land/x/deno_cache@0.4.1/disk_cache.ts": "1f3f5232cba4c56412d93bdb324c624e95d5dd179d0578d2121e3ccdf55539f9", - "https://deno.land/x/deno_cache@0.4.1/file_fetcher.ts": "07a6c5f8fd94bf50a116278cc6012b4921c70d2251d98ce1c9f3c352135c39f7", - "https://deno.land/x/deno_cache@0.4.1/http_cache.ts": "f632e0d6ec4a5d61ae3987737a72caf5fcdb93670d21032ddb78df41131360cd", - "https://deno.land/x/deno_cache@0.4.1/mod.ts": "ef1cda9235a93b89cb175fe648372fc0f785add2a43aa29126567a05e3e36195", - "https://deno.land/x/deno_cache@0.4.1/util.ts": "8cb686526f4be5205b92c819ca2ce82220aa0a8dd3613ef0913f6dc269dbbcfe", - "https://deno.land/x/deno_graph@0.26.0/lib/deno_graph.generated.js": "2f7ca85b2ceb80ec4b3d1b7f3a504956083258610c7b9a1246238c5b7c68f62d", - "https://deno.land/x/deno_graph@0.26.0/lib/loader.ts": "380e37e71d0649eb50176a9786795988fc3c47063a520a54b616d7727b0f8629", - "https://deno.land/x/deno_graph@0.26.0/lib/media_type.ts": "222626d524fa2f9ebcc0ec7c7a7d5dfc74cc401cc46790f7c5e0eab0b0787707", - "https://deno.land/x/deno_graph@0.26.0/lib/snippets/deno_graph-de651bc9c240ed8d/src/deno_apis.js": "41192baaa550a5c6a146280fae358cede917ae16ec4e4315be51bef6631ca892", - "https://deno.land/x/deno_graph@0.26.0/mod.ts": "11131ae166580a1c7fa8506ff553751465a81c263d94443f18f353d0c320bc14", - "https://deno.land/x/dir@1.5.1/data_local_dir/mod.ts": "91eb1c4bfadfbeda30171007bac6d85aadacd43224a5ed721bbe56bc64e9eb66", - "https://deno.land/x/dnt@0.38.0/lib/compiler.ts": "209ad2e1b294f93f87ec02ade9a0821f942d2e524104552d0aa8ff87021050a5", - "https://deno.land/x/dnt@0.38.0/lib/compiler_transforms.ts": "f21aba052f5dcf0b0595c734450842855c7f572e96165d3d34f8fed2fc1f7ba1", - "https://deno.land/x/dnt@0.38.0/lib/mod.deps.ts": "30367fc68bcd2acf3b7020cf5cdd26f817f7ac9ac35c4bfb6c4551475f91bc3e", - "https://deno.land/x/dnt@0.38.0/lib/npm_ignore.ts": "57fbb7e7b935417d225eec586c6aa240288905eb095847d3f6a88e290209df4e", - "https://deno.land/x/dnt@0.38.0/lib/package_json.ts": "61f35b06e374ed39ca776d29d67df4be7ee809d0bca29a8239687556c6d027c2", - "https://deno.land/x/dnt@0.38.0/lib/pkg/dnt_wasm.generated.js": "82aeecfb055af0b2700e1e9b886e4a44fe3bf9cd11a9c4195cb169f53a134b15", - "https://deno.land/x/dnt@0.38.0/lib/pkg/snippets/dnt-wasm-a15ef721fa5290c5/helpers.js": "a6b95adc943a68d513fe8ed9ec7d260ac466b7a4bced4e942f733e494bb9f1be", - "https://deno.land/x/dnt@0.38.0/lib/shims.ts": "df1bd4d9a196dca4b2d512b1564fff64ac6c945189a273d706391f87f210d7e6", - "https://deno.land/x/dnt@0.38.0/lib/test_runner/get_test_runner_code.ts": "4dc7a73a13b027341c0688df2b29a4ef102f287c126f134c33f69f0339b46968", - "https://deno.land/x/dnt@0.38.0/lib/test_runner/test_runner.ts": "4d0da0500ec427d5f390d9a8d42fb882fbeccc92c92d66b6f2e758606dbd40e6", - "https://deno.land/x/dnt@0.38.0/lib/transform.deps.ts": "e42f2bdef46d098453bdba19261a67cf90b583f5d868f7fe83113c1380d9b85c", - "https://deno.land/x/dnt@0.38.0/lib/types.ts": "b8e228b2fac44c2ae902fbb73b1689f6ab889915bd66486c8a85c0c24255f5fb", - "https://deno.land/x/dnt@0.38.0/lib/utils.ts": "878b7ac7003a10c16e6061aa49dbef9b42bd43174853ebffc9b67ea47eeb11d8", - "https://deno.land/x/dnt@0.38.0/mod.ts": "b13349fe77847cf58e26b40bcd58797a8cec5d71b31a1ca567071329c8489de1", - "https://deno.land/x/dnt@0.38.0/transform.ts": "f68743a14cf9bf53bfc9c81073871d69d447a7f9e3453e0447ca2fb78926bb1d", - "https://deno.land/x/mock_fetch@0.3.0/mod.ts": "7e7806c65ab17b2b684c334c4e565812bdaf504a3e9c938d2bb52bb67428bc89", - "https://deno.land/x/ts_morph@18.0.0/bootstrap/mod.ts": "b53aad517f106c4079971fcd4a81ab79fadc40b50061a3ab2b741a09119d51e9", - "https://deno.land/x/ts_morph@18.0.0/bootstrap/ts_morph_bootstrap.js": "6645ac03c5e6687dfa8c78109dc5df0250b811ecb3aea2d97c504c35e8401c06", - "https://deno.land/x/ts_morph@18.0.0/common/DenoRuntime.ts": "6a7180f0c6e90dcf23ccffc86aa8271c20b1c4f34c570588d08a45880b7e172d", - "https://deno.land/x/ts_morph@18.0.0/common/mod.ts": "01985d2ee7da8d1caee318a9d07664774fbee4e31602bc2bb6bb62c3489555ed", - "https://deno.land/x/ts_morph@18.0.0/common/ts_morph_common.js": "845671ca951073400ce142f8acefa2d39ea9a51e29ca80928642f3f8cf2b7700", - "https://deno.land/x/ts_morph@18.0.0/common/typescript.js": "d5c598b6a2db2202d0428fca5fd79fc9a301a71880831a805d778797d2413c59", - "https://deno.land/x/wasmbuild@0.14.1/cache.ts": "89eea5f3ce6035a1164b3e655c95f21300498920575ade23161421f5b01967f4", - "https://deno.land/x/wasmbuild@0.14.1/loader.ts": "d98d195a715f823151cbc8baa3f32127337628379a02d9eb2a3c5902dbccfc02" - } -} diff --git a/mod.ts b/mod.ts index 7eec3d7..bd9af26 100644 --- a/mod.ts +++ b/mod.ts @@ -1,7 +1,26 @@ // deno-lint-ignore-file no-explicit-any import { Api, ApiConfig, HttpResponse } from "./openapi/client.ts"; +import { createRateLimitFetch } from "./rate_limit_fetch.ts"; +import type { RateLimitFetchConfig } from "./rate_limit_fetch.ts"; + +export interface LagoClientConfig extends ApiConfig { + /** + * Rate limit retry configuration + */ + rateLimitRetry?: RateLimitFetchConfig; +} + +export const Client = (apiKey: string, apiConfig?: LagoClientConfig) => { + const { rateLimitRetry, ...restConfig } = apiConfig ?? {}; + + // Create rate-limit-aware fetch if configured + const customFetch = rateLimitRetry + ? createRateLimitFetch( + (restConfig?.customFetch ?? globalThis.fetch) as typeof fetch, + rateLimitRetry, + ) + : restConfig?.customFetch; -export const Client = (apiKey: string, apiConfig?: ApiConfig) => { const api = new Api({ securityWorker: (apiKey) => apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : {}, @@ -9,7 +28,8 @@ export const Client = (apiKey: string, apiConfig?: ApiConfig) => { baseApiParams: { redirect: "follow", }, - ...apiConfig, + ...restConfig, + ...(customFetch && { customFetch }), }); api.setSecurityData(apiKey); return api; @@ -42,4 +62,9 @@ export async function getLagoError(error: any) { throw new Error(error); } +// Rate limit exports +export { LagoRateLimitError } from "./rate_limit_error.ts"; +export { parseRateLimitHeaders, type RateLimitHeaders } from "./rate_limit_headers.ts"; +export { createRateLimitFetch, type RateLimitFetchConfig } from "./rate_limit_fetch.ts"; + export * from "./openapi/client.ts"; diff --git a/rate_limit_error.ts b/rate_limit_error.ts new file mode 100644 index 0000000..28e7931 --- /dev/null +++ b/rate_limit_error.ts @@ -0,0 +1,27 @@ +/** + * Error class for rate limit (HTTP 429) responses + */ +export class LagoRateLimitError extends Error { + public readonly limit: number; + public readonly remaining: number; + public readonly reset: number; // seconds until window resets + public readonly retryAfter: number; // milliseconds to wait before retrying + + constructor( + limit: number, + remaining: number, + reset: number, + ) { + super( + `Rate limit exceeded. Limit: ${limit}, Remaining: ${remaining}, Reset in: ${reset}s`, + ); + this.name = "LagoRateLimitError"; + this.limit = limit; + this.remaining = remaining; + this.reset = reset; + this.retryAfter = reset * 1000; // Convert seconds to milliseconds + + // Maintain proper prototype chain for instanceof checks + Object.setPrototypeOf(this, LagoRateLimitError.prototype); + } +} diff --git a/rate_limit_fetch.ts b/rate_limit_fetch.ts new file mode 100644 index 0000000..13ddfa8 --- /dev/null +++ b/rate_limit_fetch.ts @@ -0,0 +1,117 @@ +import { LagoRateLimitError } from "./rate_limit_error.ts"; +import { + parseRateLimitHeaders, + type RateLimitHeaders, +} from "./rate_limit_headers.ts"; + +/** + * Configuration for rate limit fetch behavior + */ +export interface RateLimitFetchConfig { + /** Maximum number of retries on 429 (default: 3) */ + maxRetries?: number; + /** Whether to automatically retry on rate limit (default: true) */ + retryOnRateLimit?: boolean; + /** Maximum delay in milliseconds before a retry (default: 20000) */ + maxRetryDelay?: number; +} + +/** + * Creates a fetch wrapper that handles rate limiting with automatic retry + * Compatible with both Node.js fetch and browser fetch APIs + */ +export function createRateLimitFetch( + baseFetch: typeof fetch, + config: RateLimitFetchConfig = {}, +): typeof fetch { + const maxRetries = config.maxRetries ?? 3; + const retryOnRateLimit = config.retryOnRateLimit ?? true; + const maxRetryDelay = config.maxRetryDelay ?? 20_000; + + return async function rateLimitFetch( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await baseFetch(input, init); + + // Handle 429 responses + if (response.status === 429) { + const headers = parseRateLimitHeaders(response.headers); + const limit = headers.limit ?? -1; + const remaining = headers.remaining ?? 0; + const reset = headers.reset ?? -1; + + const error = new LagoRateLimitError(limit, remaining, reset); + + if (!retryOnRateLimit) { + throw error; + } + + if (attempt === maxRetries) { + throw error; // Max retries reached + } + + // Wait before retry + const waitMs = getWaitTime(error, attempt, maxRetryDelay); + await sleep(waitMs); + continue; // Retry + } + + // Success or non-rate-limit error - return the response + return response; + } catch (error) { + lastError = error; + + if (!(error instanceof LagoRateLimitError)) { + throw error; // Not a rate limit error, rethrow immediately + } + + if (attempt === maxRetries) { + throw error; // Max retries reached + } + + // Will retry on next iteration + } + } + + throw lastError; + }; +} + +/** + * Calculate wait time before retry + * Uses the exact reset time from headers if available, otherwise exponential backoff + */ +function getWaitTime( + error: LagoRateLimitError, + attempt: number, + maxRetryDelay: number, +): number { + let waitMs: number; + + if (error.reset > 0) { + // Use the exact reset time from the header + waitMs = error.retryAfter; + } else { + // Exponential backoff: 1s, 2s, 4s, 8s, etc. + waitMs = 1000 * Math.pow(2, attempt); + } + + // Cap at maxRetryDelay + waitMs = Math.min(waitMs, maxRetryDelay); + + // Add small jitter to prevent thundering herd (max 100ms) + const jitter = Math.random() * 100; + return waitMs + jitter; +} + +/** + * Sleep for a specified number of milliseconds + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/rate_limit_headers.ts b/rate_limit_headers.ts new file mode 100644 index 0000000..b1de8d3 --- /dev/null +++ b/rate_limit_headers.ts @@ -0,0 +1,33 @@ +/** + * Parses rate limit headers from HTTP responses + */ +export interface RateLimitHeaders { + limit: number | null; + remaining: number | null; + reset: number | null; +} + +/** + * Extract rate limit information from response headers + */ +export function parseRateLimitHeaders(headers: Headers): RateLimitHeaders { + return { + limit: parseHeaderAsNumber(headers, "x-ratelimit-limit"), + remaining: parseHeaderAsNumber(headers, "x-ratelimit-remaining"), + reset: parseHeaderAsNumber(headers, "x-ratelimit-reset"), + }; +} + +/** + * Helper to parse a header value as a number + */ +function parseHeaderAsNumber( + headers: Headers, + headerName: string, +): number | null { + const value = headers.get(headerName); + if (value === null) return null; + + const num = parseInt(value, 10); + return isNaN(num) ? null : num; +} diff --git a/tests/rate_limit.test.ts b/tests/rate_limit.test.ts new file mode 100644 index 0000000..7aca6b3 --- /dev/null +++ b/tests/rate_limit.test.ts @@ -0,0 +1,262 @@ +import { assertEquals } from "../dev_deps.ts"; +import { + Client, + LagoRateLimitError, + parseRateLimitHeaders, + createRateLimitFetch, +} from "../mod.ts"; + +// Simple fetch mock helper (replaces broken mock_fetch library) +function createMockFetch(handler: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise): typeof fetch { + return (input: RequestInfo | URL, init?: RequestInit) => { + return Promise.resolve(handler(input, init)); + }; +} + +Deno.test("LagoRateLimitError contains rate limit information", () => { + const error = new LagoRateLimitError(100, 0, 60); + + assertEquals(error.limit, 100); + assertEquals(error.remaining, 0); + assertEquals(error.reset, 60); + assertEquals(error.retryAfter, 60000); // 60 seconds in milliseconds + assertEquals(error.name, "LagoRateLimitError"); +}); + +Deno.test("parseRateLimitHeaders extracts headers correctly", () => { + const headers = new Headers({ + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "10", + "x-ratelimit-reset": "45", + }); + + const result = parseRateLimitHeaders(headers); + + assertEquals(result.limit, 100); + assertEquals(result.remaining, 10); + assertEquals(result.reset, 45); +}); + +Deno.test("parseRateLimitHeaders handles missing headers", () => { + const headers = new Headers({ + "content-type": "application/json", + }); + + const result = parseRateLimitHeaders(headers); + + assertEquals(result.limit, null); + assertEquals(result.remaining, null); + assertEquals(result.reset, null); +}); + +Deno.test("parseRateLimitHeaders handles invalid header values", () => { + const headers = new Headers({ + "x-ratelimit-limit": "not-a-number", + "x-ratelimit-remaining": "abc", + "x-ratelimit-reset": "", + }); + + const result = parseRateLimitHeaders(headers); + + assertEquals(result.limit, null); + assertEquals(result.remaining, null); + assertEquals(result.reset, null); +}); + +Deno.test("createRateLimitFetch throws LagoRateLimitError on 429 without retry", async () => { + const mockFetch = createMockFetch(() => { + return new Response("Rate limited", { + status: 429, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "60", + }, + }); + }); + + const rateLimitFetch = createRateLimitFetch(mockFetch, { + retryOnRateLimit: false, + }); + + try { + await rateLimitFetch("https://example.com/api"); + assertEquals(true, false, "Should have thrown LagoRateLimitError"); + } catch (error) { + if (error instanceof LagoRateLimitError) { + assertEquals(error.limit, 100); + assertEquals(error.remaining, 0); + assertEquals(error.reset, 60); + } else { + throw error; + } + } +}); + +Deno.test("createRateLimitFetch retries on 429 with correct wait time", async () => { + let callCount = 0; + + const mockFetch = createMockFetch(() => { + callCount++; + if (callCount === 1) { + return new Response("Rate limited", { + status: 429, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "1", // 1 second wait + }, + }); + } + return new Response('{"success": true}', { + status: 200, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "99", + "x-ratelimit-reset": "3600", + }, + }); + }); + + const rateLimitFetch = createRateLimitFetch(mockFetch, { + retryOnRateLimit: true, + maxRetries: 3, + }); + + const startTime = Date.now(); + const response = await rateLimitFetch("https://example.com/api"); + const elapsed = Date.now() - startTime; + + assertEquals(response.status, 200); + assertEquals(callCount, 2); + // Should have waited approximately 1 second (1000ms) + jitter + assertEquals(elapsed >= 1000, true); +}); + +Deno.test("createRateLimitFetch respects maxRetries", async () => { + let callCount = 0; + + const mockFetch = createMockFetch(() => { + callCount++; + return new Response("Rate limited", { + status: 429, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "1", + }, + }); + }); + + const rateLimitFetch = createRateLimitFetch(mockFetch, { + retryOnRateLimit: true, + maxRetries: 2, + }); + + try { + await rateLimitFetch("https://example.com/api"); + assertEquals(true, false, "Should have thrown LagoRateLimitError"); + } catch (error) { + if (error instanceof LagoRateLimitError) { + // Called: attempt 0 (fails), attempt 1 (fails), attempt 2 (fails and gives up) + assertEquals(callCount, 3); + } else { + throw error; + } + } +}); + +Deno.test("createRateLimitFetch uses exponential backoff when reset header missing", async () => { + let callCount = 0; + + const mockFetch = createMockFetch(() => { + callCount++; + if (callCount === 1) { + // 429 without reset header + return new Response("Rate limited", { + status: 429, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "0", + }, + }); + } + return new Response('{"success": true}', { status: 200 }); + }); + + const rateLimitFetch = createRateLimitFetch(mockFetch, { + retryOnRateLimit: true, + maxRetries: 3, + }); + + const startTime = Date.now(); + const response = await rateLimitFetch("https://example.com/api"); + const elapsed = Date.now() - startTime; + + assertEquals(response.status, 200); + assertEquals(callCount, 2); + // Exponential backoff attempt 0: 1000ms * 2^0 = 1000ms + jitter + assertEquals(elapsed >= 1000, true); + assertEquals(elapsed < 1500, true); // Should not be as long as the old 60s default +}); + +Deno.test("createRateLimitFetch passes through non-429 errors", async () => { + const mockFetch = createMockFetch(() => { + return new Response("Server error", { + status: 500, + }); + }); + + const rateLimitFetch = createRateLimitFetch(mockFetch, { + retryOnRateLimit: true, + }); + + const response = await rateLimitFetch("https://example.com/api"); + assertEquals(response.status, 500); +}); + +Deno.test("Client with rateLimitRetry config uses rate limit fetch", () => { + const mockFetch = createMockFetch(() => { + return new Response('{"customers": []}', { status: 200 }); + }); + + const client = Client("test-api-key", { + baseUrl: "https://api.example.com", + customFetch: mockFetch, + rateLimitRetry: { + retryOnRateLimit: true, + maxRetries: 3, + }, + }); + + // Verify client was created + assertEquals(typeof client, "object"); +}); + +Deno.test("createRateLimitFetch includes rate limit headers in success response", async () => { + const mockFetch = createMockFetch(() => { + return new Response('{"data": "test"}', { + status: 200, + headers: { + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "50", + "x-ratelimit-reset": "3600", + "content-type": "application/json", + }, + }); + }); + + const rateLimitFetch = createRateLimitFetch(mockFetch); + const response = await rateLimitFetch("https://example.com/api"); + + assertEquals(response.status, 200); + assertEquals(response.headers.get("x-ratelimit-limit"), "100"); + assertEquals(response.headers.get("x-ratelimit-remaining"), "50"); + assertEquals(response.headers.get("x-ratelimit-reset"), "3600"); +}); + +Deno.test("LagoRateLimitError is instanceof Error", () => { + const error = new LagoRateLimitError(100, 0, 60); + assertEquals(error instanceof Error, true); + assertEquals(error instanceof LagoRateLimitError, true); +});