[
  {
    "path": ".flake8",
    "content": "[flake8]\nmax-line-length=120\nextend-ignore=E203\nexclude=\n    __init__.py\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: guilatrova\ncustom: https://www.paypal.com/donate/?business=SUQKVABPUHUUQ&no_recurring=0&item_name=Thank+you+very+much+for+considering+supporting+my+work.+%E2%9D%A4%EF%B8%8F+It+keeps+me+motivated+to+keep+producing+value+for+you.&currency_code=USD\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    paths-ignore:\n      - \"docs/**\"\n      - \"*.md\"\n\n  pull_request:\n    paths-ignore:\n      - \"docs/**\"\n      - \"*.md\"\n\njobs:\n  test:\n    # We want to run on external PRs, but not on our own internal PRs as they'll be run\n    # by the push to the branch. Without this if check, checks are duplicated since\n    # internal PRs match both the push and pull_request events.\n    if:\n      github.event_name == 'push' || github.event.pull_request.head.repo.full_name !=\n      github.repository\n\n    strategy:\n      fail-fast: false\n      matrix:\n        python-version: [3.8, 3.9, \"3.10\", 3.11]\n        os: [ubuntu-latest] #, macOS-latest, windows-latest]\n\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - uses: actions/checkout@v2\n\n      - name: Install poetry\n        shell: bash\n        run: pipx install poetry\n\n      - name: Set up Python\n        uses: actions/setup-python@v4\n        with:\n          python-version: ${{ matrix.python-version }}\n          cache: poetry\n          # key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }}\n\n      - name: Install dependencies\n        run: poetry install --with=dev\n\n      - name: Lint\n        uses: pre-commit/action@v3.0.0\n\n      - name: Unit tests\n        run: poetry run pytest -vvv\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Semantic Release\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n    concurrency: release\n\n    steps:\n      - uses: actions/checkout@v2\n        with:\n          fetch-depth: 0\n          token: ${{ secrets.GRACY_GITHUB_TOKEN }}\n\n      - name: Install poetry\n        shell: bash\n        run: pipx install poetry\n\n      - name: Set up Python 3.10\n        uses: actions/setup-python@v4\n        with:\n          python-version: \"3.10\"\n          cache: poetry\n\n      - name: Install dependencies\n        run: poetry install --with=dev\n\n      - name: Python Semantic Release\n        run: |\n          git config --global user.name \"github-actions\"\n          git config --global user.email \"action@github.com\"\n\n          poetry run semantic-release publish -D commit_author=\"github-actions <action@github.com>\"\n        env:\n          GH_TOKEN: ${{secrets.GRACY_GITHUB_TOKEN}}\n          PYPI_TOKEN: ${{secrets.PYPI_TOKEN}}\n"
  },
  {
    "path": ".gitignore",
    "content": "*.egg-info\n*.pyc\n*.log\n.DS_Store\ndist/\n.mongo\n.venv/\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v4.4.0\n    hooks:\n      - id: end-of-file-fixer\n      - id: trailing-whitespace\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.1.5\n    hooks:\n      - id: ruff\n        args: [--fix, --exit-non-zero-on-fix]\n      - id: ruff-format\n  - repo: local\n    hooks:\n      - id: pyright\n        name: pyright\n        language: system\n        types: [python]\n        entry: \"poetry run pyright\"\n        require_serial: true  # use require_serial so that script is only called once per commit\n        verbose: true  # print the number of files as a sanity-check\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"python.analysis.typeCheckingMode\": \"basic\",\n  \"editor.formatOnSave\": true,\n  \"[python]\": {\n    \"editor.rulers\": [\n      120\n    ],\n    \"editor.formatOnSave\": true,\n    \"editor.defaultFormatter\": \"charliermarsh.ruff\",\n    \"editor.codeActionsOnSave\": {\n      \"source.fixAll.ruff\": \"explicit\",\n      \"source.organizeImports.ruff\": \"explicit\"\n    },\n  },\n  \"python.testing.pytestArgs\": [\n    \"src/tests\"\n  ],\n  \"python.testing.unittestEnabled\": false,\n  \"python.testing.pytestEnabled\": true,\n  \"files.exclude\": {\n    \"**/.git\": true,\n    \"**/.svn\": true,\n    \"**/.hg\": true,\n    \"**/CVS\": true,\n    \"**/__pycache__\": true,\n    \".mypy_cache\": true,\n    \".pytest_cache\": true,\n    \"**/.DS_Store\": true,\n    \"**/Thumbs.db\": true,\n    \".venv/**\": true,\n  }\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n<!--next-version-placeholder-->\n\n## v1.34.0 (2024-11-27)\n\n### Feature\n\n* Garbage collector improvements ([`36e537e`](https://github.com/guilatrova/gracy/commit/36e537ec85fd12c273a74945cf7962cbdd7494bd))\n\n## v1.33.1 (2024-10-07)\n\n### Fix\n\n* Don't stop if ctx token reset fails ([`4ddad79`](https://github.com/guilatrova/gracy/commit/4ddad79627a5fd528a199d8bf6e58906d75cbec1))\n\n## v1.33.0 (2024-02-20)\n\n### Feature\n\n* Enhance response typing ([`95f8c16`](https://github.com/guilatrova/gracy/commit/95f8c162de7ab64a925bf008e81e11b138426320))\n\n### Fix\n\n* Add typing_extensions to support deprecated ([`f6b2b7b`](https://github.com/guilatrova/gracy/commit/f6b2b7b1baf7a39c5f6f21cced7aeeeac23cbfe7))\n\n### Documentation\n\n* Update on dynamic type hinting ([`6d7975d`](https://github.com/guilatrova/gracy/commit/6d7975d1b383d040f2c960d1dc13482530b81571))\n\n## v1.32.2 (2024-01-31)\n### Fix\n\n* Define correct typing ([`7c889fe`](https://github.com/guilatrova/gracy/commit/7c889fe782e7705843d84ec6dc1b3f0098e779e0))\n\n## v1.32.1 (2024-01-15)\n### Fix\n\n* **retry:** Verify original exc for failed requests ([`31e79d7`](https://github.com/guilatrova/gracy/commit/31e79d75e6bbeedfdbbc7f69f2c254017c21e22c))\n* Format original exception name properly ([`56254b6`](https://github.com/guilatrova/gracy/commit/56254b6e99325b550d3a33512918ab6bb0a56b9a))\n\n## v1.32.0 (2023-12-09)\n### Feature\n\n* Improve concurrency logging ([`5292faf`](https://github.com/guilatrova/gracy/commit/5292faf9eff54e9290ab2db836677f44c15b4673))\n* Add plotly printer ([`5b8a460`](https://github.com/guilatrova/gracy/commit/5b8a460ffeb8a363b2dfcbef95b3b6134c1f205a))\n* Track requests timeline ([`9c30ba2`](https://github.com/guilatrova/gracy/commit/9c30ba2d84a9485d44530df9b82a7bcaf04cd01e))\n* Replace manual impl with semaphore ([`57df089`](https://github.com/guilatrova/gracy/commit/57df08936d9f4a808cddd3857058f0f23ba3350b))\n\n### Documentation\n\n* Add explanation ([`b1ea801`](https://github.com/guilatrova/gracy/commit/b1ea80122d6915a207e95780928918c3253d532a))\n* Explain concurrent request limit ([`63efb40`](https://github.com/guilatrova/gracy/commit/63efb4039a77c697510a6eb9c47c0fd3319e0017))\n* Update badges/description ([`157daaf`](https://github.com/guilatrova/gracy/commit/157daafc3f12b4fa6641a5287688cd632a2ea851))\n\n## v1.31.0 (2023-12-03)\n### Feature\n\n* Auto instantiate namespaces ([`3d26863`](https://github.com/guilatrova/gracy/commit/3d26863444a2a959a0ca6b4665c4791547acfbec))\n\n## v1.30.0 (2023-12-02)\n### Feature\n\n* Add paginator ([`f8a1db8`](https://github.com/guilatrova/gracy/commit/f8a1db8108b2662c88c182679a71f2b4bb4476fd))\n\n## v1.29.0 (2023-12-02)\n### Feature\n\n* Add generated_parsed_response ([`d9add8e`](https://github.com/guilatrova/gracy/commit/d9add8e208c54f72a08df5bfd4b66192c7aba751))\n\n### Fix\n\n* Specify parsed response type properly ([`2ef4566`](https://github.com/guilatrova/gracy/commit/2ef4566069e8f9a6bec129f393eb8b12c946b139))\n\n### Documentation\n\n* Explain parsed_response ([`792fd32`](https://github.com/guilatrova/gracy/commit/792fd32af1bed7ed2584f166082c6ca35d9cdaae))\n\n## v1.28.1 (2023-11-30)\n### Fix\n\n* Resolve deploy issue ([`2ae3717`](https://github.com/guilatrova/gracy/commit/2ae3717aa4620e114e750b01eb363f49b0f0fd97))\n\n## v1.28.0 (2023-11-30)\n### Feature\n\n* Introduce namespaces ([`67a70f3`](https://github.com/guilatrova/gracy/commit/67a70f3550a77a37d309fcb65800b1b6759fb8f8))\n\n### Documentation\n\n* Write about namespaces ([`468e205`](https://github.com/guilatrova/gracy/commit/468e205fddb1166770bddb4c8c241668b27d03a7))\n\n## v1.27.1 (2023-08-08)\n### Fix\n\n* Use Union for python 3.8 ([`02415ac`](https://github.com/guilatrova/gracy/commit/02415aca8009e7bcae70f93b0c0b54c5a7d61473))\n\n## v1.27.0 (2023-08-08)\n### Feature\n\n* Add concurrent calls ([`69976d3`](https://github.com/guilatrova/gracy/commit/69976d36d25fe45ae7b6cefe5e8800bf926aa4f8))\n\n### Documentation\n\n* Add note about concurrent requests ([`487dbed`](https://github.com/guilatrova/gracy/commit/487dbedbf707dbe38caf4529be1488ad72a0f2d9))\n\n## v1.26.0 (2023-08-06)\n### Feature\n\n* Count ongoing requests ([`9495d4e`](https://github.com/guilatrova/gracy/commit/9495d4ed98839562fc470da2964d370638b6f727))\n\n### Fix\n\n* Use int over HttpStatus enum ([`abebe93`](https://github.com/guilatrova/gracy/commit/abebe938191b760f64490477abad595259537c79))\n\n### Documentation\n\n* Add common hooks ([`3a85897`](https://github.com/guilatrova/gracy/commit/3a85897baf65222e156e9762cd3f09d8bc35e2e4))\n\n## v1.25.0 (2023-06-30)\n### Feature\n\n* **replays:** Support log events for replays ([`f9c6b80`](https://github.com/guilatrova/gracy/commit/f9c6b8071302b2262b2973830e7c3d378ae64288))\n\n### Fix\n\n* **reports:** Hide replays if display disabled ([`68692e0`](https://github.com/guilatrova/gracy/commit/68692e0e94ed1ec1d81ea343787abadd34ce3340))\n\n## v1.24.1 (2023-06-30)\n### Fix\n\n* **replays:** Pass discard flag ([`2083a52`](https://github.com/guilatrova/gracy/commit/2083a524ed515758ccf1ff6b814d41eafd64ebf5))\n\n## v1.24.0 (2023-06-30)\n### Feature\n\n* **replays:** Allow to discard bad status ([`9926ae3`](https://github.com/guilatrova/gracy/commit/9926ae3282ab9632261af204734cbfcf2fce721e))\n* **loggers:** Add REPLAY placeholder ([`911a9ec`](https://github.com/guilatrova/gracy/commit/911a9ecdcb83bf877350b96d325047cea80edbd5))\n* **reports:** Show replays ([`fc82bd7`](https://github.com/guilatrova/gracy/commit/fc82bd7322f35a61506bc2b42bc53c51d0ae1029))\n* Track replays per request ([`e7322a3`](https://github.com/guilatrova/gracy/commit/e7322a3b31aa55d31e93e71cda43ba1ee55c53c2))\n* **hooks:** Don't wait if replayed ([`785959f`](https://github.com/guilatrova/gracy/commit/785959f92a8bcacba9d7523a15c7682294bf2a00))\n* **replays:** Add flag when replayed ([`8ae8394`](https://github.com/guilatrova/gracy/commit/8ae839489e338b8fbde18e96771dc7765587a6b2))\n\n## v1.23.0 (2023-06-28)\n### Feature\n\n* **hooks:** Make reporter optional + dryrun ([`ea0ec00`](https://github.com/guilatrova/gracy/commit/ea0ec007ff5cbd19d1371fe157e9d563d50230bb))\n* **hooks:** Add RateLimitBackOffHook ([`d941c68`](https://github.com/guilatrova/gracy/commit/d941c6880494c41171792fe54b9b6189bba964b9))\n* Allow to modify retry-after ([`a5f043d`](https://github.com/guilatrova/gracy/commit/a5f043dc4a93fa2ade28967d8b019e483d52c6f7))\n* **hooks:** Return hook result ([`ee8f102`](https://github.com/guilatrova/gracy/commit/ee8f10235bced2ed6fe8af1abe219e51950d943f))\n\n### Fix\n\n* **retry:** Validate log correctly ([`dbddcf5`](https://github.com/guilatrova/gracy/commit/dbddcf5f881f2873f3149bd25e4f06ad930b6c51))\n\n## v1.22.0 (2023-06-27)\n### Feature\n\n* **retry:** Add RETRY_CAUSE to log ([`3bf1c72`](https://github.com/guilatrova/gracy/commit/3bf1c72776874b3b77f5620ce57e34a912e0a197))\n* **retry:** Add cause ([`2b468fd`](https://github.com/guilatrova/gracy/commit/2b468fde1c1a8d3374268c54814bdfb4f992f191))\n\n### Fix\n\n* **retry:** Check override for status ([`1b9383d`](https://github.com/guilatrova/gracy/commit/1b9383d0e340c3a422e87f69c17ab1e097ca7112))\n* Make it compatible with py3.8 ([`96e3e61`](https://github.com/guilatrova/gracy/commit/96e3e610eee324047c63adcddd5a6fa534cb51a8))\n* **retry:** Set last rep for overriden retry ([`d0241e5`](https://github.com/guilatrova/gracy/commit/d0241e54a4f6cbd5f0b5541366cae3c3a64eb6c3))\n* Typo and wrong attrs ([`07d30e0`](https://github.com/guilatrova/gracy/commit/07d30e09a4767084bbfcca5654682486b579bb42))\n\n## v1.21.1 (2023-06-26)\n### Fix\n\n* Expose OverrideRetryOn ([`0f14782`](https://github.com/guilatrova/gracy/commit/0f14782aa870efcec829cd9efb2bc72ea101305e))\n\n## v1.21.0 (2023-06-26)\n### Feature\n\n* **hooks:** Implement flag for diff locks ([`dcce610`](https://github.com/guilatrova/gracy/commit/dcce6104f1129ee4710b50fc034c1a8629f57cf2))\n\n## v1.20.0 (2023-06-26)\n### Feature\n\n* **hooks:** Handle dates for retry_after header ([`3feef65`](https://github.com/guilatrova/gracy/commit/3feef652b1de9aa32b551c54307965f24a92e1b7))\n* **hooks:** Add a common hook ([`04734ad`](https://github.com/guilatrova/gracy/commit/04734adcbea3ecc3cde93e1330f58992bb20b3be))\n* **retry:** Implement retry override ([`0045413`](https://github.com/guilatrova/gracy/commit/004541385ca1956d6d3cc3c4807ec09cf9999177))\n\n## v1.19.0 (2023-06-11)\n### Feature\n\n* Include cause/context to the req exception ([`b2a96f1`](https://github.com/guilatrova/gracy/commit/b2a96f1caba229ef8ae2b102f160b107bde1f9dd))\n\n## v1.18.0 (2023-06-10)\n### Feature\n\n* Create request failed exc ([`8087365`](https://github.com/guilatrova/gracy/commit/8087365ebbefa1de8801fc232553e1a5f5d954b5))\n\n### Fix\n\n* **replays:** Handle content type none ([`0229218`](https://github.com/guilatrova/gracy/commit/02292182f99d925ea3cb3d916ba940489a6984e0))\n\n## v1.17.3 (2023-05-31)\n### Fix\n\n* **deps:** Requires httpx>=0.23 ([`c3fc7c0`](https://github.com/guilatrova/gracy/commit/c3fc7c08387204eee825a5b6bd17d10d41706d78))\n\n## v1.17.2 (2023-05-18)\n### Fix\n* Don't pass every kwargs to build_request ([`192838e`](https://github.com/guilatrova/gracy/commit/192838ed0dada8f0dbf30aa7a15a42efe7ad50b0))\n\n## v1.17.1 (2023-05-17)\n### Fix\n* Support py38+ ([`1ac2f56`](https://github.com/guilatrova/gracy/commit/1ac2f56741094e5c0b4586093f8fa8cb26720d02))\n\n## v1.17.0 (2023-05-13)\n### Feature\n* Count/display data about replays ([`658b5f5`](https://github.com/guilatrova/gracy/commit/658b5f52adf34d5d6e577abc7e6b9cd33a745919))\n* Implement skip throttle ([`efb259c`](https://github.com/guilatrova/gracy/commit/efb259c151ee0807af325901155b67184de062e2))\n\n## v1.16.0 (2023-05-13)\n### Feature\n* Pass retry state to after hook ([`218e510`](https://github.com/guilatrova/gracy/commit/218e510d84a3acf1fbee84141ec5f7123672cd6b))\n* Implement gracy hooks ([`243dddb`](https://github.com/guilatrova/gracy/commit/243dddbd58845cbc92a0b84eaf44c612a125daf5))\n* Implement hooks ([`7a88e4c`](https://github.com/guilatrova/gracy/commit/7a88e4c93e3517b9ed095f179582f0fb2809e48a))\n\n### Fix\n* Resolve recursion for hooks ([`e1be02b`](https://github.com/guilatrova/gracy/commit/e1be02bb20395c207353de2ea3bae8d839a34c03))\n\n### Documentation\n* **hooks:** Add example about hook ([`3cb52db`](https://github.com/guilatrova/gracy/commit/3cb52db7600213252bb36d6de8442bd487fd57b7))\n\n## v1.15.0 (2023-05-11)\n### Feature\n* Show 'Aborts' as title ([`8485409`](https://github.com/guilatrova/gracy/commit/8485409e899e5d4591754ad62e35cfa4a128f124))\n* **reports:** Show retries/throttles ([`f6de12a`](https://github.com/guilatrova/gracy/commit/f6de12a51a95b7c0ac8d0302004a3ad8c0d2e146))\n\n## v1.14.0 (2023-05-11)\n### Feature\n* Default safe format + retry status code ([`5d7f834`](https://github.com/guilatrova/gracy/commit/5d7f834db146284813341d55979e25b373855606))\n* Display aborted requests ([`67ac1ed`](https://github.com/guilatrova/gracy/commit/67ac1ed103248a8f65890826fc6732ec20adb683))\n\n### Documentation\n* Add note about graceful request ([`7e14c80`](https://github.com/guilatrova/gracy/commit/7e14c80205bd56df9297d2a169c3529397b4f05a))\n\n## v1.13.0 (2023-05-10)\n### Feature\n* Track broken requests ([`e40d8b8`](https://github.com/guilatrova/gracy/commit/e40d8b8774c86f766c69c0cd8f0d5d5b65f09d0f))\n* Capture broken requests (without a response) ([`bf0ac44`](https://github.com/guilatrova/gracy/commit/bf0ac44e87f96d7acc41f7f0e63411ac0f113a67))\n\n## v1.12.0 (2023-05-04)\n### Feature\n* Improve decorator typing ([`72233d6`](https://github.com/guilatrova/gracy/commit/72233d60dd84cfddf2778b585b1260833f357c1e))\n\n## v1.11.4 (2023-05-04)\n### Fix\n* Add support for `graceful_generator` ([`22ecf9a`](https://github.com/guilatrova/gracy/commit/22ecf9ac91064fcc4288f38ff73a77f4e165b98d))\n\n## v1.11.3 (2023-03-24)\n### Fix\n* Make exception pickable ([`16d6a62`](https://github.com/guilatrova/gracy/commit/16d6a6248fd46a565c411743a3bf0f74dac94363))\n\n### Documentation\n* Show custom request timeout ([`e2a069b`](https://github.com/guilatrova/gracy/commit/e2a069b46a01cbcbf5bd2a9507d7d25505ecbd83))\n\n## v1.11.2 (2023-03-03)\n### Fix\n* Log exhausted when appropriate ([`8c5d622`](https://github.com/guilatrova/gracy/commit/8c5d622fef7aa6dd2514cfaaf867445f56d7b04a))\n* Retry considers last validation result ([`595177f`](https://github.com/guilatrova/gracy/commit/595177f50e396f4ca7b2dcc1c8ed535928a0aca7))\n* Handle retry edge case ([`077e6f4`](https://github.com/guilatrova/gracy/commit/077e6f49d80cb6d886c31aa010a4f814a6953445))\n* Retry result is used as response ([`8687156`](https://github.com/guilatrova/gracy/commit/8687156991058fa24043dc39658f0a12377a21f6))\n\n### Documentation\n* Add httpbin example ([`1babd10`](https://github.com/guilatrova/gracy/commit/1babd1098a46c4d0bc24ed228d76bb094260ad5e))\n\n## v1.11.1 (2023-02-23)\n### Fix\n* **retry:** Don't retry when successful ([`b334c22`](https://github.com/guilatrova/gracy/commit/b334c227a4a8a688029130c736118b6dcb4f8f3b))\n* **pymongo:** Adjust filter ([`5ee9f0c`](https://github.com/guilatrova/gracy/commit/5ee9f0c6aa523530929bd69d19a9ff637c46705c))\n* **pymongo:** Use correct methods/kwargs ([`4a191d8`](https://github.com/guilatrova/gracy/commit/4a191d81e083772add036bc3d9d5937ccbf6d31c))\n\n### Documentation\n* Update examples ([`26420da`](https://github.com/guilatrova/gracy/commit/26420da78776862a0cb7569b5f64b610ed212ff6))\n\n## v1.11.0 (2023-02-23)\n### Feature\n* Enable config debugging flag ([`07c6339`](https://github.com/guilatrova/gracy/commit/07c633923a20343329aa884ddc109f3cde0e5be0))\n\n## v1.10.1 (2023-02-23)\n### Fix\n* Error log ([`6f63941`](https://github.com/guilatrova/gracy/commit/6f6394181ed024f738605a4743af2eea788ce4f7))\n\n## v1.10.0 (2023-02-22)\n### Feature\n* Allow custom validators ([`50818f8`](https://github.com/guilatrova/gracy/commit/50818f89fe2a03800fde18fa38686a04853cb54a))\n\n### Fix\n* Implement proper validate/retry/parse logic ([`0b2fa75`](https://github.com/guilatrova/gracy/commit/0b2fa75228c9340efb8595fee801c0cfa3303619))\n* Raise exception correctly ([`10a90b5`](https://github.com/guilatrova/gracy/commit/10a90b5159a2fce3e24c1bfac7f4b9e0cb58d059))\n\n### Documentation\n* Add exception details to retry params ([`8d69234`](https://github.com/guilatrova/gracy/commit/8d692346369b5c83d05e746ec1b7e9f924d02cbd))\n* Enhance custom validator example ([`d5e02eb`](https://github.com/guilatrova/gracy/commit/d5e02eb032739639f9ceb655b5b88c39f8c9a0f6))\n* Add validators ([`e3e8fa6`](https://github.com/guilatrova/gracy/commit/e3e8fa672e5f95d02f60dc3af762b6e6cd189d4d))\n\n## v1.9.1 (2023-02-21)\n### Fix\n* Create tuples ([`f648f85`](https://github.com/guilatrova/gracy/commit/f648f85a5787b2cd86934051640e666815fe5864))\n\n## v1.9.0 (2023-02-21)\n### Feature\n* Make exceptions pickable ([`5ab62c5`](https://github.com/guilatrova/gracy/commit/5ab62c59ac273078e7a1ef3122e76bf0c6901e70))\n\n### Documentation\n* Reword ([`0ca061f`](https://github.com/guilatrova/gracy/commit/0ca061f1b1e73c73b01808e2d9f0258f03e0fefa))\n* Add a emoji ([`8da07ae`](https://github.com/guilatrova/gracy/commit/8da07aecd8da6642edf01a94475ff49f297c1886))\n* Reword ([`a54f1f7`](https://github.com/guilatrova/gracy/commit/a54f1f7bac2b7a5fb52485b31c746e58734066d0))\n* Reorder logging customization ([`f6d9d76`](https://github.com/guilatrova/gracy/commit/f6d9d765daee63e7e863426519f8acda5bc2c5f0))\n\n## v1.8.1 (2023-02-17)\n### Fix\n* Retry logic triggers only once ([`0fc2358`](https://github.com/guilatrova/gracy/commit/0fc2358b1631eacc0587a59afe1d21b419f8679e))\n\n## v1.8.0 (2023-02-17)\n### Feature\n* Calculate throttling await properly ([`ba520e0`](https://github.com/guilatrova/gracy/commit/ba520e034bab88b2b5a258473f8a2ba7ff7c5879))\n* Lock throttling logs properly ([`a8ebd69`](https://github.com/guilatrova/gracy/commit/a8ebd69df0e5184a6a806870a12888c202ba37d8))\n* Prevent floats for max_requests ([`b9aed74`](https://github.com/guilatrova/gracy/commit/b9aed746bdfcd672920baeb047cf02b31e146503))\n* Format rule time range ([`514cbae`](https://github.com/guilatrova/gracy/commit/514cbaeeb2d02de12f60a62e8285ce0ba1ad0437))\n* Allow custom time windows for throttling ([`7fc35f0`](https://github.com/guilatrova/gracy/commit/7fc35f09e4a5e8df50a746cf95d112b08d4dd9bc))\n\n### Fix\n* Correct kwargs ([`0db5925`](https://github.com/guilatrova/gracy/commit/0db59254081d479a20c411ab346cad605e3a2efb))\n\n### Documentation\n* Add `THROTTLE_TIME_RANGE` ([`299c200`](https://github.com/guilatrova/gracy/commit/299c2008b5da43e7a52035dc285375b0b1dfc093))\n* **throttling:** Add timedelta example ([`74c20ef`](https://github.com/guilatrova/gracy/commit/74c20ef91c521165b72c999c7212268ca83ec7cc))\n* Enhance throttling example ([`200b3c5`](https://github.com/guilatrova/gracy/commit/200b3c5adac8a16f3af002d56f2e3c8b84f3f0d3))\n\n## v1.7.1 (2023-02-14)\n### Fix\n* **retry:** Remove duplicated default msg ([`963d7e8`](https://github.com/guilatrova/gracy/commit/963d7e8237a85c5f5692a01d7a3d1c0eb733b752))\n\n### Documentation\n* Fix reports/replay order ([`b4ddf79`](https://github.com/guilatrova/gracy/commit/b4ddf792fe29ae49e981fda5b1fca0bec4aca0f9))\n\n## v1.7.0 (2023-02-12)\n### Feature\n* Handle missing replays ([`4395b83`](https://github.com/guilatrova/gracy/commit/4395b832cd9f75a88d696d5cba2eb7bd9f7ce61d))\n* Report show replay mode ([`b488975`](https://github.com/guilatrova/gracy/commit/b4889755c75c3f3a14507b27b3d57ba243b5c828))\n* Implement replay load w/ sqlite ([`4fa4cf6`](https://github.com/guilatrova/gracy/commit/4fa4cf6983ed64d82560d47c813bfeb4cfa5ed66))\n* Implement replay (store only) w/ sqlite ([`797c2b9`](https://github.com/guilatrova/gracy/commit/797c2b95334f5ebfd9b17555278b1be44b7eeef2))\n\n### Fix\n* Handle 0 requests for logger printer ([`09e471c`](https://github.com/guilatrova/gracy/commit/09e471c791e34c9b30427c6903bb19c8c25338aa))\n\n### Documentation\n* Add details about custom replay storage ([`f03407f`](https://github.com/guilatrova/gracy/commit/f03407fbd66a40850d679541b0616fc7847c8b5c))\n* Add brief explanation about replay ([`edd1a24`](https://github.com/guilatrova/gracy/commit/edd1a24fb255d8ed23288f277769b923e6af218b))\n\n## v1.6.1 (2023-02-11)\n### Fix\n* Gracy supports Python >=3.8 ([`a3623a9`](https://github.com/guilatrova/gracy/commit/a3623a98a7459dcba3dc78ca11917be5c6c5a82d))\n\n## v1.6.0 (2023-02-07)\n### Feature\n* Handle parsing failures ([`ac48952`](https://github.com/guilatrova/gracy/commit/ac489522a98412d65b85ac3317dbe6083d8819ad))\n\n### Documentation\n* Fix syntax ([`9996b39`](https://github.com/guilatrova/gracy/commit/9996b39f505d3221f2e63d78bd311e90f2608349))\n\n## v1.5.0 (2023-02-05)\n### Feature\n* Protect lambda custom msg from unknown keys ([`d6da853`](https://github.com/guilatrova/gracy/commit/d6da8536d1b561fd606d2911749d99309aa92460))\n* Implement lambda for loggers ([`e7d9248`](https://github.com/guilatrova/gracy/commit/e7d9248475ce9dab92913cc7fa7eb6554c9676d7))\n\n### Fix\n* Use correct typing for coroutine ([`65296cd`](https://github.com/guilatrova/gracy/commit/65296cdddf925126ea47e591f7def242b0e6b6da))\n\n### Documentation\n* Add report examples ([`269810c`](https://github.com/guilatrova/gracy/commit/269810c4d205e5356672287f08c3d34d3bc0c3f0))\n\n## v1.4.0 (2023-02-05)\n### Feature\n* Implement the logger printer ([`40298f5`](https://github.com/guilatrova/gracy/commit/40298f5204a499730f93d2d79bbfed43dc754b0c))\n* Implement the list printer ([`9adee2d`](https://github.com/guilatrova/gracy/commit/9adee2d9ea78a569ab1541724b86fb73b06a4f2e))\n* Split rich as optional dep ([`ae169df`](https://github.com/guilatrova/gracy/commit/ae169df066871d4095b95c032e7ec06b85ab3249))\n\n### Documentation\n* Fix bad information ([`e1a6746`](https://github.com/guilatrova/gracy/commit/e1a67466a9403dc87719cde9079a0f2b0ed7b16f))\n* Fix bad syntax example ([`116b9bf`](https://github.com/guilatrova/gracy/commit/116b9bf0e1ed6fabdb9e5d365ade7d92ab8d3429))\n\n## v1.3.0 (2023-02-01)\n### Feature\n* Use locks for throttled requests ([`b2db6a7`](https://github.com/guilatrova/gracy/commit/b2db6a760b097b27142f17bf533d760e4e99605c))\n\n### Fix\n* Throttling/allowed not working ([`cb0251b`](https://github.com/guilatrova/gracy/commit/cb0251b49c43f9376783e6f457073410f6d326a1))\n\n## v1.2.1 (2023-02-01)\n### Fix\n* Handle scenarios for just 1 request per url ([`f4f799b`](https://github.com/guilatrova/gracy/commit/f4f799bbc03ae318fba69dd299fb423800a18651))\n\n## v1.2.0 (2023-02-01)\n### Feature\n* Simplify req/s rate to the user ([`1b428c7`](https://github.com/guilatrova/gracy/commit/1b428c788f192e0e23c49b27d9a46438d20d230a))\n* Include req rate in report ([`e387a25`](https://github.com/guilatrova/gracy/commit/e387a25f831a27f031ebc1625ac642beb3895678))\n* Clear base urls with ending slash ([`51fb8ee`](https://github.com/guilatrova/gracy/commit/51fb8ee9e369eecd951fb31da92edc3317e63483))\n* Implement retry logging ([`f2d3238`](https://github.com/guilatrova/gracy/commit/f2d3238830bbda163b8b55f874f2ae7ecb11d6df))\n\n### Fix\n* Consider retry is unset ([`0ca1ed9`](https://github.com/guilatrova/gracy/commit/0ca1ed9e65faa8e1e7efd024a7264dbc328a3259))\n* Retry must start with 1 ([`3e3e750`](https://github.com/guilatrova/gracy/commit/3e3e75003092bca7f4181c17b68a873ec77c31d1))\n\n### Documentation\n* Fix download badge ([`22a9d7a`](https://github.com/guilatrova/gracy/commit/22a9d7a132b86c6da084b6f59ddba74f64814238))\n* Improve examples ([`4ca1f7d`](https://github.com/guilatrova/gracy/commit/4ca1f7df80b6b1bba9f255983a6be5b906b09a85))\n* Add new placeholders ([`8eba619`](https://github.com/guilatrova/gracy/commit/8eba619dd73544861960b0a9a381fe97d2c5468f))\n* Add some notes for custom exceptions ([`225f008`](https://github.com/guilatrova/gracy/commit/225f00828697d8a611bb596e1f3119570a1b363e))\n\n## v1.1.0 (2023-01-30)\n### Feature\n* Change api to be public ([`3b0c828`](https://github.com/guilatrova/gracy/commit/3b0c8281c3e164d9a7f01770c698fa825afe562a))\n\n### Documentation\n* Fix examples/info ([`0193f11`](https://github.com/guilatrova/gracy/commit/0193f112807f4621f5fd35acc9fbec32c4a2554c))\n\n## v1.0.0 (2023-01-30)\n### Feature\n* Drop python 3.7 support ([`0f69e5b`](https://github.com/guilatrova/gracy/commit/0f69e5be00f8202ea2aa98b71630ae167c6431f1))\n\n### Breaking\n* drop python 3.7 support ([`0f69e5b`](https://github.com/guilatrova/gracy/commit/0f69e5be00f8202ea2aa98b71630ae167c6431f1))\n\n### Documentation\n* Add remaining sections ([`4335b5a`](https://github.com/guilatrova/gracy/commit/4335b5a3313a56c36b7b54c9ec44a07b2e6b4bd0))\n* Add throttling ([`6fc9583`](https://github.com/guilatrova/gracy/commit/6fc958328fcbc5304e745c29918f8ffb2f8fa1a4))\n* Add retry ([`aa8a828`](https://github.com/guilatrova/gracy/commit/aa8a82844a8c77f99897512d23b01eb216b8e0ff))\n* Add credits/settings section ([`113bf48`](https://github.com/guilatrova/gracy/commit/113bf4886ae50418ddaef62d6f4880171f98240f))\n* Write about parsing ([`c133cda`](https://github.com/guilatrova/gracy/commit/c133cda6444058861a5129db5da0a4fd7a12965e))\n* Remove colspans ([`3ef5fd7`](https://github.com/guilatrova/gracy/commit/3ef5fd77dbec659144a034405c815fa5a060d747))\n* Add logging details ([`09e923c`](https://github.com/guilatrova/gracy/commit/09e923cb9bb14b858f8c6ab975fb50ffab8fd42a))\n* Fix badge ([`fea301a`](https://github.com/guilatrova/gracy/commit/fea301a63db98398101ae796f3a14f35882922f7))\n* Add empty topics ([`887b46c`](https://github.com/guilatrova/gracy/commit/887b46ca3a61d20fcc942e18868a159ffaded0f1))\n* Improve top description ([`e745403`](https://github.com/guilatrova/gracy/commit/e745403116483c651ebcd9f7e26fe99ab468ad03))\n\n## v0.6.0 (2023-01-29)\n### Feature\n* Implement throttling ([`8691045`](https://github.com/guilatrova/gracy/commit/869104595b7c6954ea31b159e89a1efe8028215c))\n\n### Fix\n* **throttling:** Resolve bugs ([`4c41326`](https://github.com/guilatrova/gracy/commit/4c4132608b61256b8949dcbc46558641bccceedf))\n* **throttling:** Handle some scenarios ([`f9d4fbc`](https://github.com/guilatrova/gracy/commit/f9d4fbc5c2e6e378cdfdd7dc8a930852f9620477))\n\n### Documentation\n* Improve prop description ([`27f9e01`](https://github.com/guilatrova/gracy/commit/27f9e01dd5004827a8df471034138ad1bf18b10c))\n\n## v0.5.0 (2023-01-29)\n### Feature\n* Implement custom exceptions ([`2d89ebd`](https://github.com/guilatrova/gracy/commit/2d89ebd4c862c60bfc816774c3102c8e9e43ed2a))\n* Implement retry pass ([`45e8ce6`](https://github.com/guilatrova/gracy/commit/45e8ce6124127ef69f5a9704a6ae0dc4a48d1f45))\n\n## v0.4.0 (2023-01-29)\n### Feature\n* Implement parser ([`ab48cd9`](https://github.com/guilatrova/gracy/commit/ab48cd937cfa37e4455260defa94a8d41620f878))\n\n### Documentation\n* Add custom logo ([`19f6bf8`](https://github.com/guilatrova/gracy/commit/19f6bf86b4daf68ee50908cf2833912b0f3de852))\n\n## v0.3.0 (2023-01-29)\n### Feature\n* Improve client customization ([`1372b4f`](https://github.com/guilatrova/gracy/commit/1372b4fb9ba7fc6d2c9b8f5e3064f4e2c9fd9ab5))\n\n## v0.2.0 (2023-01-28)\n### Feature\n* Calculate footer totals ([`eb77c71`](https://github.com/guilatrova/gracy/commit/eb77c7138c50511cb1d4edfbd7c6f77b52ca6989))\n* Sort table by requests made desc ([`fced5eb`](https://github.com/guilatrova/gracy/commit/fced5eb47dcc87abc97f2d91b1905140ad4d65d9))\n* Add custom color to status ([`9964723`](https://github.com/guilatrova/gracy/commit/99647237155aa0f8b6d236ffbaa71d6d616c4ea7))\n* Fold correct column ([`4a0bff0`](https://github.com/guilatrova/gracy/commit/4a0bff08a67e3c549897cac3ce9ec6d94603c2e7))\n\n## v0.1.0 (2023-01-28)\n### Feature\n* Fold url column ([`a4b0ed0`](https://github.com/guilatrova/gracy/commit/a4b0ed0c1b2fe2b313c113e9ecdb6020b2f949a4))\n* Display status range in metrics ([`8d01476`](https://github.com/guilatrova/gracy/commit/8d0147613c83c708064d05033ba1e7a24d3fa6cf))\n* Add custom color to failed requests ([`65c9ab7`](https://github.com/guilatrova/gracy/commit/65c9ab7c2db0f90b1a3f48c4ab74eb2d3a96dd42))\n* Add rich table to display metrics ([`44944f7`](https://github.com/guilatrova/gracy/commit/44944f7874f474fc33b4f532260a65720df0c051))\n* Implement logs ([`9caee55`](https://github.com/guilatrova/gracy/commit/9caee5576f9d8cf3f9a17429b54e5dd26df9fb15))\n* Add stub for report ([`b394afe`](https://github.com/guilatrova/gracy/commit/b394afe66a5fadb3c4831f2ceb75842b717465b4))\n* Narrow down retry logic ([`e444281`](https://github.com/guilatrova/gracy/commit/e444281be9f0e8d9752e0ae847a768fddd1c1586))\n* Make gracy async ([`5edacca`](https://github.com/guilatrova/gracy/commit/5edacca8781c02b7046a636020a7847faf716e8e))\n* Implement retry ([`f0a794a`](https://github.com/guilatrova/gracy/commit/f0a794a40a6d351b02b516fa3a0004798a0710c2))\n* Implement strict/allowed status code ([`171688b`](https://github.com/guilatrova/gracy/commit/171688b591c0c88b825f5ff1590f55c5cf0e1a9d))\n\n### Fix\n* Use enum value for _str_ ([`345464f`](https://github.com/guilatrova/gracy/commit/345464f44a48d864d5a39e56dfadf94f6f55da16))\n\n### Documentation\n* Reword some stuff ([`546f3fc`](https://github.com/guilatrova/gracy/commit/546f3fc6188c312196c9ca69a5fb80e172b6738f))\n* Slightly improve readme ([`8a56b3d`](https://github.com/guilatrova/gracy/commit/8a56b3d961cf3ad343d7c95412ce49184e914608))\n* Fill with some gracy stuff ([`8183d26`](https://github.com/guilatrova/gracy/commit/8183d2686f8f3a4cdfc50bf8e13465edfc54ef6d))\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2021 Guilherme Latrova\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n    <img src=\"https://raw.githubusercontent.com/guilatrova/gracy/main/img/logo.png\">\n</p>\n\n<h2 align=\"center\">Python's most graceful API Client Framework</h2>\n\n<p align=\"center\">\n  <!-- CI --><a href=\"https://github.com/guilatrova/gracy/actions\"><img alt=\"Actions Status\" src=\"https://github.com/guilatrova/gracy/workflows/CI/badge.svg\"></a>\n  <!-- PyPI --><a href=\"https://pypi.org/project/gracy/\"><img alt=\"PyPI\" src=\"https://img.shields.io/pypi/v/gracy\"/></a>\n  <!-- Supported Python versions --><img src=\"https://badgen.net/pypi/python/gracy\" />\n  <!-- Alternative Python versioning: <img alt=\"python version\" src=\"https://img.shields.io/badge/python-3.9%20%7C%203.10-blue\"> -->\n  <!-- PyPI downloads --><a href=\"https://pepy.tech/project/gracy/\"><img alt=\"Downloads\" src=\"https://static.pepy.tech/badge/gracy/week\"/></a>\n  <!-- LICENSE --><a href=\"https://github.com/guilatrova/gracy/blob/main/LICENSE\"><img alt=\"GitHub\" src=\"https://img.shields.io/github/license/guilatrova/gracy\"/></a>\n  <!-- Formatting --><a href=\"https://github.com/psf/black\"><img alt=\"Code style: black\" src=\"https://img.shields.io/badge/code%20style-black-000000.svg\"/></a>\n  <!-- Tryceratops --><a href=\"https://github.com/guilatrova/tryceratops\"><img alt=\"try/except style: tryceratops\" src=\"https://img.shields.io/badge/try%2Fexcept%20style-tryceratops%20%F0%9F%A6%96%E2%9C%A8-black\" /></a>\n  <!-- Typing --><a href=\"https://github.com/microsoft/pyright\"><img alt=\"Types: pyright\" src=\"https://img.shields.io/badge/types-pyright-blue.svg\"/></a>\n  <!-- Follow handle --><a href=\"https://twitter.com/intent/user?screen_name=guilatrova\"><img alt=\"Follow guilatrova\" src=\"https://img.shields.io/twitter/follow/guilatrova?style=social\"/></a>\n  <!-- Sponsor --><a href=\"https://github.com/sponsors/guilatrova\"><img alt=\"Sponsor guilatrova\" src=\"https://img.shields.io/github/sponsors/guilatrova?logo=GitHub%20Sponsors&style=social\"/></a>\n</p>\n\nGracy handles failures, logging, retries, throttling, parsing, and reporting for all your HTTP interactions. Gracy uses [httpx](https://github.com/encode/httpx) under the hood.\n\n> \"Let Gracy do the boring stuff while you focus on your application\"\n\n---\n\n**Summary**\n\n- [🧑‍💻 Get started](#-get-started)\n  - [Installation](#installation)\n  - [Usage](#usage)\n    - [Simple example](#simple-example)\n    - [More examples](#more-examples)\n- [Settings](#settings)\n  - [Strict/Allowed status code](#strictallowed-status-code)\n  - [Custom Validators](#custom-validators)\n  - [Parsing](#parsing)\n  - [Parsing Typing](#parsing-typing)\n  - [Retry](#retry)\n  - [Throttling](#throttling)\n  - [Concurrent Requests](#concurrent-requests)\n  - [Logging](#logging)\n  - [Custom Exceptions](#custom-exceptions)\n- [Reports](#reports)\n  - [Logger](#logger)\n  - [List](#list)\n  - [Table](#table)\n  - [Plotly](#plotly)\n- [Replay requests](#replay-requests)\n  - [Recording](#recording)\n  - [Replay](#replay)\n- [Resource Namespacing](#resource-namespacing)\n- [Pagination](#pagination)\n- [Advanced Usage](#advanced-usage)\n  - [Customizing/Overriding configs per method](#customizingoverriding-configs-per-method)\n  - [Customizing HTTPx client](#customizing-httpx-client)\n  - [Overriding default request timeout](#overriding-default-request-timeout)\n  - [Creating a custom Replay data source](#creating-a-custom-replay-data-source)\n  - [Hooks before/after request](#hooks-beforeafter-request)\n    - [Common Hooks](#common-hooks)\n      - [`HttpHeaderRetryAfterBackOffHook`](#httpheaderretryafterbackoffhook)\n      - [`RateLimitBackOffHook`](#ratelimitbackoffhook)\n- [📚 Extra Resources](#-extra-resources)\n- [Change log](#change-log)\n- [License](#license)\n- [Credits](#credits)\n\n\n## 🧑‍💻 Get started\n\n### Installation\n\n```\npip install gracy\n```\n\nOR\n\n```\npoetry add gracy\n```\n\n### Usage\n\nExamples will be shown using the [PokeAPI](https://pokeapi.co).\n\n#### Simple example\n\n```py\n# 0. Import\nimport asyncio\nimport typing as t\nfrom gracy import BaseEndpoint, Gracy, GracyConfig, LogEvent, LogLevel\n\n# 1. Define your endpoints\nclass PokeApiEndpoint(BaseEndpoint):\n    GET_POKEMON = \"/pokemon/{NAME}\" # 👈 Put placeholders as needed\n\n# 2. Define your Graceful API\nclass GracefulPokeAPI(Gracy[str]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\" # 👈 Optional BASE_URL\n        # 👇 Define settings to apply for every request\n        SETTINGS = GracyConfig(\n          log_request=LogEvent(LogLevel.DEBUG),\n          log_response=LogEvent(LogLevel.INFO, \"{URL} took {ELAPSED}\"),\n          parser={\n            \"default\": lambda r: r.json()\n          }\n        )\n\n    async def get_pokemon(self, name: str) -> t.Awaitable[dict]:\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\npokeapi = GracefulPokeAPI()\n\nasync def main():\n    try:\n      pokemon = await pokeapi.get_pokemon(\"pikachu\")\n      print(pokemon)\n\n    finally:\n        pokeapi.report_status(\"rich\")\n\n\nasyncio.run(main())\n```\n\n#### More examples\n\n- [PokeAPI with retries, parsers, logs](./examples/pokeapi.py)\n- [PokeAPI with throttling](./examples/pokeapi_throttle.py)\n- [PokeAPI with SQLite replay](./examples/pokeapi_replay.py)\n- [PokeAPI with Mongo replay](./examples/pokeapi_replay_mongo.py)\n\n## Settings\n\n### Strict/Allowed status code\n\nBy default Gracy considers any successful status code (200-299) as successful.\n\n**Strict**\n\nYou can modify this behavior by defining a strict status code or increase the range of allowed status codes:\n\n```py\nfrom http import HTTPStatus\n\nGracyConfig(\n  strict_status_code=HTTPStatus.CREATED\n)\n```\n\nor a list of values:\n\n```py\nfrom http import HTTPStatus\n\nGracyConfig(\n  strict_status_code={HTTPStatus.OK, HTTPStatus.CREATED}\n)\n```\n\nUsing `strict_status_code` means that any other code not specified will raise an error regardless of being successful or not.\n\n**Allowed**\n\nYou can also keep the behavior, but extend the range of allowed codes.\n\n```py\nfrom http import HTTPStatus\n\nGracyConfig(\n  allowed_status_code=HTTPStatus.NOT_FOUND\n)\n```\n\nor a list of values\n\n\n```py\nfrom http import HTTPStatus\n\nGracyConfig(\n  allowed_status_code={HTTPStatus.NOT_FOUND, HTTPStatus.FORBIDDEN}\n)\n```\n\nUsing `allowed_status_code` means that all successful codes plus your defined codes will be considered successful.\n\nThis is quite useful for parsing as you'll see soon.\n\n⚠️ Note that `strict_status_code` takes precedence over `allowed_status_code`, probably you don't want to combine those. Prefer one or the other.\n\n### Custom Validators\n\nYou can implement your own custom validator to do further checks on the response and decide whether to consider the request failed (and as consequence trigger retries if they're set).\n\n```py\nfrom gracy import GracefulValidator\n\nclass MyException(Exception):\n  pass\n\nclass MyCustomValidator(GracefulValidator):\n    def check(self, response: httpx.Response) -> None:\n        jsonified = response.json()\n        if jsonified.get('error', None):\n          raise MyException(\"Error is not expected\")\n\n        return None\n\n...\n\nclass Config:\n  SETTINGS = GracyConfig(\n    ...,\n    retry=GracefulRetry(retry_on=MyException, ...),  # Set up retry to work whenever our validator fails\n    validators=MyCustomValidator(),  # Set up validator\n  )\n\n```\n\n### Parsing\n\nParsing allows you to handle the request based on the status code returned.\n\nThe basic example is parsing `json`:\n\n```py\nGracyConfig(\n  parser={\n    \"default\": lambda r: r.json()\n  }\n)\n```\n\nIn this example all successful requests will automatically return the `json()` result.\n\nYou can also narrow it down to handle specific status codes.\n\n```py\nclass Config:\n  SETTINGS = GracyConfig(\n    ...,\n    allowed_status_code=HTTPStatusCode.NOT_FOUND,\n    parser={\n      \"default\": lambda r: r.json()\n      HTTPStatusCode.NOT_FOUND: None\n    }\n  )\n\nasync def get_pokemon(self, name: str) -> dict| None:\n  # 👇 Returns either dict or None\n  return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n```\n\nOr even customize [exceptions to improve your code readability](https://guicommits.com/handling-exceptions-in-python-like-a-pro/):\n\n```py\nclass PokemonNotFound(GracyUserDefinedException):\n  ... # More on exceptions below\n\nclass Config:\n  GracyConfig(\n    ...,\n    allowed_status_code=HTTPStatusCode.NOT_FOUND,\n    parser={\n      \"default\": lambda r: r.json()\n      HTTPStatusCode.NOT_FOUND: PokemonNotFound\n    }\n  )\n\nasync def get_pokemon(self, name: str) -> Awaitable[dict]:\n  # 👇 Returns either dict or raises PokemonNotFound\n  return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n```\n\n### Parsing Typing\n\nBecause parsers allow you to dynamically parse a payload based on the status code your IDE will not identify the return type by itself.\n\nTo avoid boring `typing.cast` for every method, Gracy provides typed http methods, so you can define a specific return type:\n\n```py\nasync def list(self, offset: int = 0, limit: int = 20):\n  params = dict(offset=offset, limit=limit)\n  return await self.get[ResourceList]( # Specifies this method return a `ResourceList`\n    PokeApiEndpoint.BERRY_LIST, params=params\n  )\n\nasync def get_one(self, name_or_id: str | int):\n  return await self.get[models.Berry | None](\n    PokeApiEndpoint.BERRY_GET, params=dict(KEY=str(name_or_id))\n  )\n```\n\n### Retry\n\nWho doesn't hate flaky APIs? 🙋\n\nYet there're many of them.\n\nUsing tenacity, backoff, retry, aiohttp_retry, and any other retry libs is **NOT easy enough**. 🙅\n\nYou still would need to code the implementation for each request which is annoying.\n\nHere's how Gracy allows you to implement your retry logic:\n\n```py\nclass Config:\n  GracyConfig(\n    retry=GracefulRetry(\n      delay=1,\n      max_attempts=3,\n      delay_modifier=1.5,\n      retry_on=None,\n      log_before=None,\n      log_after=LogEvent(LogLevel.WARNING),\n      log_exhausted=LogEvent(LogLevel.CRITICAL),\n      behavior=\"break\",\n    )\n  )\n```\n\n| Parameter        | Description                                                                                                     | Example                                                                                                                              |\n| ---------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |\n| `delay`          | How many seconds to wait between retries                                                                        | `2` would wait 2 seconds, `1.5` would wait 1.5 seconds, and so on                                                                    |\n| `max_attempts`   | How many times should Gracy retry the request?                                                                  | `10` means 1 regular request with additional 10 retries in case they keep failing. `1` should be the minimum                         |\n| `delay_modifier` | Allows you to specify increasing delay times by multiplying this value to `delay`                               | Setting `1` means no delay change. Setting `2` means delay will be doubled every retry                                               |\n| `retry_on`       | Should we retry for which status codes/exceptions? `None` means for any non successful status code or exception | `HTTPStatus.BAD_REQUEST`, or `{HTTPStatus.BAD_REQUEST, HTTPStatus.FORBIDDEN}`, or `Exception` or `{Exception, HTTPStatus.NOT_FOUND}` |\n| `log_before`     | Specify log level. `None` means don't log                                                                       | More on logging later                                                                                                                |\n| `log_after`      | Specify log level. `None` means don't log                                                                       | More on logging later                                                                                                                |\n| `log_exhausted`  | Specify log level. `None` means don't log                                                                       | More on logging later                                                                                                                |\n| `behavior`       | Allows you to define how to deal if the retry fails. `pass` will accept any retry failure                       | `pass` or `break` (default)                                                                                                          |\n| `overrides`      | Allows to override `delay` based on last response status code                                                   | `{HTTPStatus.BAD_REQUEST: OverrideRetryOn(delay=0), HTTPStatus.INTERNAL_SERVER_ERROR: OverrideRetryOn(delay=10)}`                    |\n\n\n### Throttling\n\nRate limiting issues? No more.\n\nGracy helps you proactively deal with it before any API throws 429 in your face.\n\n**Creating rules**\n\nYou can define rules per endpoint using regex:\n\n```py\nSIMPLE_RULE = ThrottleRule(\n  url_pattern=r\".*\",\n  max_requests=2\n)\nprint(SIMPLE_RULE)\n# Output: \"2 requests per second for URLs matching re.compile('.*')\"\n\nCOMPLEX_RULE = ThrottleRule(\n  url_pattern=r\".*\\/pokemon\\/.*\",\n  max_requests=10,\n  per_time=timedelta(minutes=1, seconds=30),\n)\nprint(COMPLEX_RULE)\n# Output: 10 requests per 90 seconds for URLs matching re.compile('.*\\\\/pokemon\\\\/.*')\n```\n\n**Setting throttling**\n\nYou can set up logging and assign rules as:\n\n```py\nclass Config:\n  GracyConfig(\n    throttling=GracefulThrottle(\n        rules=ThrottleRule(r\".*\", 2), # 2 reqs/s for any endpoint\n        log_limit_reached=LogEvent(LogLevel.ERROR),\n        log_wait_over=LogEvent(LogLevel.WARNING),\n    ),\n  )\n```\n\n### Concurrent Requests\n\nMaybe the API you're hitting have some slow endpoints and you want to ensure that no more than a custom number of requests are being made concurrently.\n\nYou can define a `ConcurrentRequestLimit` config.\n\nThe simplest usage is:\n\n```py\nfrom gracy import ConcurrentRequestLimit\n\n\nclass Config:\n  GracyConfig(\n    concurrent_requests=ConcurrentRequestLimit(\n      limit=1, # How many concurrent requests\n      log_limit_reached=LogEvent(LogLevel.WARNING),\n      log_limit_freed=LogEvent(LogLevel.INFO),\n    ),\n  )\n```\n\nBut you can also define it easily per method as:\n\n```py\nclass MyApiClient(Gracy[Endpoint]):\n\n  @graceful(concurrent_requests=5)\n  async def get_concurrently_five(self, name: str):\n      ...\n```\n\n### Logging\n\nYou can **define and customize logs** for events by using `LogEvent` and `LogLevel`:\n\n```py\nverbose_log = LogEvent(LogLevel.CRITICAL)\ncustom_warn_log = LogEvent(LogLevel.WARNING, custom_message=\"{METHOD} {URL} is quite slow and flaky\")\ncustom_error_log = LogEvent(LogLevel.INFO, custom_message=\"{URL} returned a bad status code {STATUS}, but that's fine\")\n```\n\nNote that placeholders are formatted and replaced later on by Gracy based on the event type, like:\n\n**Placeholders per event**\n\n| Placeholder             | Description                                                   | Example                                                        | Supported Events     |\n| ----------------------- | ------------------------------------------------------------- | -------------------------------------------------------------- | -------------------- |\n| `{URL}`                 | Full url being targetted                                      | `https://pokeapi.co/api/v2/pokemon/pikachu`                    | *All*                |\n| `{UURL}`                | Full **Unformatted** url being targetted                      | `https://pokeapi.co/api/v2/pokemon/{NAME}`                     | *All*                |\n| `{ENDPOINT}`            | Endpoint being targetted                                      | `/pokemon/pikachu`                                             | *All*                |\n| `{UENDPOINT}`           | **Unformatted** endpoint being targetted                      | `/pokemon/{NAME}`                                              | *All*                |\n| `{METHOD}`              | HTTP Request being used                                       | `GET`, `POST`                                                  | *All*                |\n| `{STATUS}`              | Status code returned by the response                          | `200`, `404`, `501`                                            | *After Request*      |\n| `{ELAPSED}`             | Amount of seconds taken for the request to complete           | *Numeric*                                                      | *After Request*      |\n| `{REPLAY}`              | A placeholder that is displayed only when request is replayed | `REPLAYED` when replay, otherwise it's a blank str (``)        | *After Request*      |\n| `{IS_REPLAY}`           | Boolean value to show whether it's replayed or not            | String with `TRUE` when replayed or `FALSE`                    | *After Request*      |\n| `{RETRY_DELAY}`         | How long Gracy will wait before repeating the request         | *Numeric*                                                      | *Any Retry event*    |\n| `{RETRY_CAUSE}`         | What caused the retry logic to trigger                        | `[Bad Status Code: 404]`, `[Request Error: ConnectionTimeout]` | *Any Retry event*    |\n| `{CUR_ATTEMPT}`         | Current attempt count for the current request                 | *Numeric*                                                      | *Any Retry event*    |\n| `{MAX_ATTEMPT}`         | Max attempt defined for the current request                   | *Numeric*                                                      | *Any Retry event*    |\n| `{THROTTLE_LIMIT}`      | How many reqs/s is defined for the current request            | *Numeric*                                                      | *Any Throttle event* |\n| `{THROTTLE_TIME}`       | How long Gracy will wait before calling the request           | *Numeric*                                                      | *Any Throttle event* |\n| `{THROTTLE_TIME_RANGE}` | Time range defined by the throttling rule                     | `second`, `90 seconds`                                         | *Any Throttle event* |\n\nand you can set up the log events as follows:\n\n**Requests**\n\n1. Before request\n2. After response\n3. Response has non successful errors\n\n```py\nGracyConfig(\n  log_request=LogEvent(),\n  log_response=LogEvent(),\n  log_errors=LogEvent(),\n)\n```\n\n**Retry**\n\n1. Before retry\n2. After retry\n3. When retry exhausted\n\n```py\nGracefulRetry(\n  ...,\n  log_before=LogEvent(),\n  log_after=LogEvent(),\n  log_exhausted=LogEvent(),\n)\n```\n\n**Throttling**\n\n1. When reqs/s limit is reached\n2. When limit decreases again\n\n```py\nGracefulThrottle(\n  ...,\n  log_limit_reached=LogEvent()\n  log_wait_over=LogEvent()\n)\n```\n\n**Dynamic Customization**\n\nYou can customize it even further by passing a lambda:\n\n```py\nLogEvent(\n    LogLevel.ERROR,\n    lambda r: \"Request failed with {STATUS}\" f\" and it was {'redirected' if r.is_redirect else 'NOT redirected'}\"\n    if r\n    else \"\",\n)\n```\n\nConsider that:\n\n- Not all log events have the response available, so you need to guard yourself against it\n- Placeholders still works (e.g. `{STATUS}`)\n- You need to watch out for some attrs that might break the formatting logic (e.g. `r.headers`)\n\n### Custom Exceptions\n\nYou can define custom exceptions for more [fine grained control over your exception messages/types](https://guicommits.com/how-to-structure-exception-in-python-like-a-pro/).\n\nThe simplest you can do is:\n\n```py\nfrom gracy import Gracy, GracyConfig\nfrom gracy.exceptions import GracyUserDefinedException\n\nclass MyCustomException(GracyUserDefinedException):\n  pass\n\nclass MyApi(Gracy[str]):\n  class Config:\n    SETTINGS = GracyConfig(\n      ...,\n      parser={\n        HTTPStatus.BAD_REQUEST: MyCustomException\n      }\n    )\n```\n\nThis will raise your custom exception under the conditions defined in your parser.\n\nYou can improve it even further by customizing your message:\n\n```py\nclass PokemonNotFound(GracyUserDefinedException):\n    BASE_MESSAGE = \"Unable to find a pokemon with the name [{NAME}] at {URL} due to {STATUS} status\"\n\n    def _format_message(self, request_context: GracyRequestContext, response: httpx.Response) -> str:\n        format_args = self._build_default_args()\n        name = request_context.endpoint_args.get(\"NAME\", \"Unknown\")\n        return self.BASE_MESSAGE.format(NAME=name, **format_args)\n```\n\n## Reports\n\n### Logger\n\nRecommended for production environments.\n\nGracy reports a short summary using `logger.info`.\n\n```python\npokeapi = GracefulPokeAPI()\n# do stuff with your API\npokeapi.report_status(\"logger\")\n\n# OUTPUT\n❯ Gracy tracked that 'https://pokeapi.co/api/v2/pokemon/{NAME}' was hit 1 time(s) with a success rate of 100.00%, avg latency of 0.45s, and a rate of 1.0 reqs/s.\n❯ Gracy tracked a total of 2 requests with a success rate of 100.00%, avg latency of 0.24s, and a rate of 1.0 reqs/s.\n```\n\n### List\n\nUses `print` to generate a short list with all attributes:\n\n```python\npokeapi = GracefulPokeAPI()\n# do stuff with your API\npokeapi.report_status(\"list\")\n\n# OUTPUT\n   ____\n  / ___|_ __ __ _  ___ _   _\n | |  _| '__/ _` |/ __| | | |\n | |_| | | | (_| | (__| |_| |\n  \\____|_|  \\__,_|\\___|\\__, |\n                       |___/  Requests Summary Report\n\n\n1. https://pokeapi.co/api/v2/pokemon/{NAME}\n    Total Reqs (#): 1\n       Success (%): 100.00%\n          Fail (%): 0.00%\n   Avg Latency (s): 0.39\n   Max Latency (s): 0.39\n         2xx Resps: 1\n         3xx Resps: 0\n         4xx Resps: 0\n         5xx Resps: 0\n      Avg Reqs/sec: 1.0 reqs/s\n\n\n2. https://pokeapi.co/api/v2/generation/{ID}/\n    Total Reqs (#): 1\n       Success (%): 100.00%\n          Fail (%): 0.00%\n   Avg Latency (s): 0.04\n   Max Latency (s): 0.04\n         2xx Resps: 1\n         3xx Resps: 0\n         4xx Resps: 0\n         5xx Resps: 0\n      Avg Reqs/sec: 1.0 reqs/s\n\n\nTOTAL\n    Total Reqs (#): 2\n       Success (%): 100.00%\n          Fail (%): 0.00%\n   Avg Latency (s): 0.21\n   Max Latency (s): 0.00\n         2xx Resps: 2\n         3xx Resps: 0\n         4xx Resps: 0\n         5xx Resps: 0\n      Avg Reqs/sec: 1.0 reqs/s\n```\n\n### Table\n\nIt requires you to install [Rich](https://github.com/Textualize/rich).\n\n```py\npokeapi = GracefulPokeAPI()\n# do stuff with your API\npokeapi.report_status(\"rich\")\n```\n\nHere's an example of how it looks:\n\n![Report](https://raw.githubusercontent.com/guilatrova/gracy/main/img/report-rich-example.png)\n\n\n### Plotly\n\nIt requires you to install [plotly 📊](https://github.com/plotly/plotly.py) and [pandas 🐼](https://github.com/pandas-dev/pandas).\n\n```py\npokeapi = GracefulPokeAPI()\n# do stuff with your API\nplotly_fig = pokeapi.report_status(\"plotly\")\nplotly_fig.show()\n```\n\nHere's an example of how it looks:\n\n![Report](https://raw.githubusercontent.com/guilatrova/gracy/main/img/report-plotly-example.png)\n\n## Replay requests\n\nGracy allows you to replay requests and responses from previous interactions.\n\nThis is powerful because it allows you to test APIs without latency or consuming your rate limit. Now writing unit tests that relies on third-party APIs is doable.\n\nIt works in two steps:\n\n| **Step**     | **Description**                                                                | **Hits the API?** |\n| ------------ | ------------------------------------------------------------------------------ | ----------------- |\n| 1. Recording | Stores all requests/responses to be later replayed                             | **Yes**           |\n| 2. Replay    | Returns all previously generated responses based on your request as a \"replay\" | No                |\n\n### Recording\n\nThe effort to record requests/responses is ZERO. You just need to pass a recording config to your Graceful API:\n\n```py\nfrom gracy import GracyReplay\nfrom gracy.replays.storages.sqlite import SQLiteReplayStorage\n\nrecord_mode = GracyReplay(\"record\", SQLiteReplayStorage(\"pokeapi.sqlite3\"))\npokeapi = GracefulPokeAPI(record_mode)\n```\n\n**Every request** will be recorded to the defined data source.\n\n### Replay\n\nOnce you have recorded all your requests you can enable the replay mode:\n\n```py\nfrom gracy import GracyReplay\nfrom gracy.replays.storages.sqlite import SQLiteReplayStorage\n\nreplay_mode = GracyReplay(\"replay\", SQLiteReplayStorage(\"pokeapi.sqlite3\"))\npokeapi = GracefulPokeAPI(replay_mode)\n```\n\n**Every request** will be routed to the defined data source resulting in faster responses.\n\n**⚠️ Note that parsers, retries, throttling, and similar configs will work as usual**.\n\n\n## Resource Namespacing\n\nYou can have multiple namespaces to organize your API endpoints as you wish.\n\nTo do so, you just have to inherit from `GracyNamespace` and instantiate it within the `GracyAPI`:\n\n```py\nfrom gracy import Gracy, GracyNamespace, GracyConfig\n\nclass PokemonNamespace(GracyNamespace[PokeApiEndpoint]):\n    async def get_one(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n\nclass BerryNamespace(GracyNamespace[PokeApiEndpoint]):\n    async def get_one(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_BERRY, {\"NAME\": name})\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n        SETTINGS = GracyConfig(\n            retry=RETRY,\n            allowed_status_code={HTTPStatus.NOT_FOUND},\n            parser={HTTPStatus.NOT_FOUND: None},\n        )\n\n    # These will be automatically assigned on init\n    berry: BerryNamespace\n    pokemon: PokemonNamespace\n```\n\nAnd the usage will work as:\n\n```py\nawait pokeapi.pokemon.get_one(\"pikachu\")\nawait pokeapi.berry.get_one(\"cheri\")\n```\n\nNote all configs are propagated to namespaces, but namespaces can still have their own which would cause merges when instantiatedg.\n\n\n## Pagination\n\nThere're endpoints that may require pagination. For that you can use `GracyPaginator`.\n\nFor a simple case where you pass `offset` and `limit`, you can use `GracyOffsetPaginator`:\n\n```py\nfrom gracy import GracyOffsetPaginator\n\nclass BerryNamespace(GracyNamespace[PokeApiEndpoint]):\n    @parsed_response(ResourceList)\n    async def list(self, offset: int = 0, limit: int = 20):\n        params = dict(offset=offset, limit=limit)\n        return await self.get(PokeApiEndpoint.BERRY_LIST, params=params)\n\n    def paginate(self, limit: int = 20) -> GracyOffsetPaginator[ResourceList]:\n        return GracyOffsetPaginator[ResourceList](\n            gracy_func=self.list,\n            has_next=lambda r: bool(r[\"next\"]) if r else True,\n            page_size=limit,\n        )\n\n```\n\nand then use it as:\n\n```py\nasync def main():\n    api = PokeApi()\n    paginator = api.berry.paginate(2)\n\n    # Just grabs the next page\n    first = await paginator.next_page()\n    print(first)\n\n    # Resets current page to 0\n    paginator.set_page(0)\n\n    # Loop throught it all\n    async for page in paginator:\n        print(page)\n```\n\n## Advanced Usage\n\n### Customizing/Overriding configs per method\n\nAPIs may return different responses/conditions/payloads based on the endpoint.\n\nYou can override any `GracyConfig` on a per method basis by using the `@graceful` decorator.\n\nNOTE: Use `@graceful_generator` if your function uses `yield`.\n\n```python\nfrom gracy import Gracy, GracyConfig, GracefulRetry, graceful, graceful_generator\n\nretry = GracefulRetry(...)\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n        SETTINGS = GracyConfig(\n            retry=retry,\n            log_errors=LogEvent(\n                LogLevel.ERROR, \"How can I become a pokemon master if {URL} keeps failing with {STATUS}\"\n            ),\n        )\n\n    @graceful(\n        retry=None, # 👈 Disables retry set in Config\n        log_errors=None, # 👈 Disables log_errors set in Config\n        allowed_status_code=HTTPStatus.NOT_FOUND,\n        parser={\n            \"default\": lambda r: r.json()[\"order\"],\n            HTTPStatus.NOT_FOUND: None,\n        },\n    )\n    async def maybe_get_pokemon_order(self, name: str):\n        val: str | None = await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n        return val\n\n    @graceful( # 👈 Retry and log_errors are still set for this one\n      strict_status_code=HTTPStatus.OK,\n      parser={\"default\": lambda r: r.json()[\"order\"]},\n    )\n    async def get_pokemon_order(self, name: str):\n      val: str = await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n      return val\n\n    @graceful_generator( # 👈 Retry and log_errors are still set for this one\n      parser={\"default\": lambda r: r.json()[\"order\"]},\n    )\n    async def get_2_pokemons(self):\n      names = [\"charmander\", \"pikachu\"]\n\n      for name in names:\n          r = await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n          yield r\n```\n\n### Customizing HTTPx client\n\nYou might want to modify the HTTPx client settings, do so by:\n\n```py\nclass YourAPIClient(Gracy[str]):\n    class Config:\n        ...\n\n    def __init__(self, token: token) -> None:\n        self._token = token\n        super().__init__()\n\n    # 👇 Implement your logic here\n    def _create_client(self) -> httpx.AsyncClient:\n        client = super()._create_client()\n        client.headers = {\"Authorization\": f\"token {self._token}\"}  # type: ignore\n        return client\n```\n\n### Overriding default request timeout\n\nAs default Gracy won't enforce a request timeout.\n\nYou can define your own by setting it on Config as:\n\n```py\nclass GracefulAPI(GracyApi[str]):\n  class Config:\n    BASE_URL = \"https://example.com\"\n    REQUEST_TIMEOUT = 10.2  # 👈 Here\n```\n\n### Creating a custom Replay data source\n\nGracy was built with extensibility in mind.\n\nYou can create your own storage to store/load anywhere (e.g. SQL Database), here's an example:\n\n```py\nimport httpx\nfrom gracy import GracyReplayStorage\n\nclass MyCustomStorage(GracyReplayStorage):\n  def prepare(self) -> None: # (Optional) Executed upon API instance creation.\n    ...\n\n  async def record(self, response: httpx.Response) -> None:\n    ... # REQUIRED. Your logic to store the response object. Note the httpx.Response has request data.\n\n  async def _load(self, request: httpx.Request) -> httpx.Response:\n    ... # REQUIRED. Your logic to load a response object based on the request.\n\n\n# Usage\nrecord_mode = GracyReplay(\"record\", MyCustomStorage())\nreplay_mode = GracyReplay(\"replay\", MyCustomStorage())\n\npokeapi = GracefulPokeAPI(record_mode)\n```\n\n### Hooks before/after request\n\nYou can set up hooks simply by defining `async def before` and `async def after` methods.\n\n⚠️ NOTE: Gracy configs are disabled within these methods which means that retries/parsers/throttling won't take effect inside it.\n\n```py\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n        SETTINGS = GracyConfig(\n            retry=RETRY,\n            allowed_status_code={HTTPStatus.NOT_FOUND},\n            parser={HTTPStatus.NOT_FOUND: None},\n        )\n\n    def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:\n        self.before_count = 0\n\n        self.after_status_counter = defaultdict[HTTPStatus, int](int)\n        self.after_aborts = 0\n        self.after_retries_counter = 0\n\n        super().__init__(*args, **kwargs)\n\n    async def before(self, context: GracyRequestContext):\n        self.before_count += 1\n\n    async def after(\n        self,\n        context: GracyRequestContext, # Current request context\n        response_or_exc: httpx.Response | Exception,  # Either the request or an error\n        retry_state: GracefulRetryState | None,  # Set when this is generated from a retry\n    ):\n        if retry_state:\n            self.after_retries_counter += 1\n\n        if isinstance(response_or_exc, httpx.Response):\n            self.after_status_counter[HTTPStatus(response_or_exc.status_code)] += 1\n        else:\n            self.after_aborts += 1\n\n    async def get_pokemon(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n```\n\nIn the example above invoking `get_pokemon()` will trigger `before()`/`after()` hooks in sequence.\n\n#### Common Hooks\n\n##### `HttpHeaderRetryAfterBackOffHook`\n\nThis hook checks for 429 (TOO MANY REQUESTS), and then reads the\n[`retry-after` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After).\n\nIf the value is set, then Gracy pauses **ALL** client requests until the time is over. This behavior can be modified to happen on a per-endpoint basis if `lock_per_endpoint` is True.\n\nExample Usage:\n\n```py\nfrom gracy.common_hooks import HttpHeaderRetryAfterBackOffHook\n\nclass GracefulAPI(GracyAPI[Endpoint]):\n  def __init__(self):\n    self._retry_after_hook = HttpHeaderRetryAfterBackOffHook(\n        self._reporter,\n        lock_per_endpoint=True,\n        log_event=LogEvent(\n            LogLevel.WARNING,\n            custom_message=(\n                \"{ENDPOINT} produced {STATUS} and requested to wait {RETRY_AFTER}s \"\n                \"- waiting {RETRY_AFTER_ACTUAL_WAIT}s\"\n            ),\n        ),\n        # Wait +10s to avoid this from happening again too soon\n        seconds_processor=lambda secs_requested: secs_requested + 10,\n    )\n\n    super().__init__()\n\n  async def before(self, context: GracyRequestContext):\n    await self._retry_after_hook.before(context)\n\n  async def after(\n    self,\n    context: GracyRequestContext,\n    response_or_exc: httpx.Response | Exception,\n    retry_state: GracefulRetryState | None,\n  ):\n    retry_after_result = await self._retry_after_hook.after(context, response_or_exc)\n```\n\n##### `RateLimitBackOffHook`\n\nThis hook checks for 429 (TOO MANY REQUESTS) and locks requests for an arbitrary amount of time defined by you.\n\nIf the value is set, then Gracy pauses **ALL** client requests until the time is over.\nThis behavior can be modified to happen on a per-endpoint basis if `lock_per_endpoint` is True.\n\n\n```py\nfrom gracy.common_hooks import RateLimitBackOffHook\n\nclass GracefulAPI(GracyAPI[Endpoint]):\n  def __init__(self):\n    self._ratelimit_backoff_hook = RateLimitBackOffHook(\n      30,\n      self._reporter,\n      lock_per_endpoint=True,\n      log_event=LogEvent(\n          LogLevel.INFO,\n          custom_message=\"{UENDPOINT} got rate limited, waiting for {WAIT_TIME}s\",\n      ),\n    )\n\n    super().__init__()\n\n  async def before(self, context: GracyRequestContext):\n    await self._ratelimit_backoff_hook.before(context)\n\n  async def after(\n    self,\n    context: GracyRequestContext,\n    response_or_exc: httpx.Response | Exception,\n    retry_state: GracefulRetryState | None,\n  ):\n    backoff_result = await self._ratelimit_backoff_hook.after(context, response_or_exc)\n```\n\n\n```py\nfrom gracy.common_hooks import HttpHeaderRetryAfterBackOffHook, RateLimitBackOffHook\n```\n\n## 📚 Extra Resources\n\nSome good practices I learned over the past years guided Gracy's philosophy, you might benefit by reading:\n\n- [How to log](https://guicommits.com/how-to-log-in-python-like-a-pro/)\n- [How to handle exceptions](https://guicommits.com/handling-exceptions-in-python-like-a-pro/)\n  - [How to structure exceptions](https://guicommits.com/how-to-structure-exception-in-python-like-a-pro/)\n- [How to use Async correctly](https://guicommits.com/effective-python-async-like-a-pro/)\n- [Book: Python like a PRO](https://guilatrova.gumroad.com/l/python-like-a-pro)\n- [Book: Effective Python](https://amzn.to/3bEVHpG)\n\n<!-- ## Contributing -->\n<!-- Thank you for considering making Gracy better for everyone! -->\n<!-- Refer to [Contributing docs](docs/CONTRIBUTING.md).-->\n\n## Change log\n\nSee [CHANGELOG](CHANGELOG.md).\n\n## License\n\nMIT\n\n## Credits\n\nThanks to the last three startups I worked which forced me to do the same things and resolve the same problems over and over again. I got sick of it and built this lib.\n\nMost importantly: **Thanks to God**, who allowed me (a random 🇧🇷 guy) to work for many different 🇺🇸 startups. This is ironic since due to God's grace, I was able to build Gracy. 🙌\n\nAlso, thanks to the [httpx](https://github.com/encode/httpx) and [rich](https://github.com/Textualize/rich) projects for the beautiful and simple APIs that powers Gracy.\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: \"3.9\"\n\nservices:\n  gracy_mongo:\n    image: mongo:latest\n    restart: always\n    environment:\n      MONGO_INITDB_ROOT_USERNAME: root\n      MONGO_INITDB_ROOT_PASSWORD: example\n    ports:\n      - 27017:27017\n    volumes:\n      - .mongo/data:/data/db\n"
  },
  {
    "path": "examples/httpbin_post.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport httpx\nimport typing as t\n\nfrom gracy import Gracy\n\n\nclass GracefulHttpbin(Gracy[str]):\n    class Config:\n        BASE_URL = \"https://httpbin.org/\"\n\n    async def post_json_example(self):\n        res = await self.post(\n            \"post\", None, json={\"test\": \"json\"}, headers={\"header1\": \"1\"}\n        )\n        return res\n\n    async def post_data_example(self):\n        res = await self.post(\"post\", None, data=\"data\", headers={\"header2\": \"2\"})\n        return res\n\n\nasync def main():\n    api = GracefulHttpbin()\n\n    json_res = t.cast(httpx.Response, await api.post_json_example())\n    data_res = t.cast(httpx.Response, await api.post_data_example())\n\n    print(json_res.json())\n    print(\"-\" * 100)\n    print(data_res.json())\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/memory.py",
    "content": "from __future__ import annotations\n\nimport httpx\nfrom dataclasses import dataclass\nfrom time import sleep\n\nfrom gracy import (\n    BaseEndpoint,\n    Gracy,\n    GracyRequestContext,\n)\nfrom gracy.exceptions import GracyUserDefinedException\n\n\nclass PokemonNotFound(GracyUserDefinedException):\n    BASE_MESSAGE = \"Unable to find a pokemon with the name [{NAME}] at {URL} due to {STATUS} status\"\n\n    def _format_message(\n        self, request_context: GracyRequestContext, response: httpx.Response\n    ) -> str:\n        format_args = self._build_default_args()\n        name = request_context.endpoint_args.get(\"NAME\", \"Unknown\")\n        return self.BASE_MESSAGE.format(NAME=name, **format_args)\n\n\nclass PokeApiEndpoint(BaseEndpoint):\n    GET_POKEMON = \"/pokemon/{NAME}\"\n    GET_GENERATION = \"/generation/{ID}\"\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n\n    pass\n\n\n@dataclass\nclass Test:\n    pass\n\n\ndef main():\n    while True:\n        GracefulPokeAPI()\n        sleep(1)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/pokeapi.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport httpx\nimport typing as t\nfrom http import HTTPStatus\n\nfrom gracy import (\n    BaseEndpoint,\n    GracefulRetry,\n    Gracy,\n    GracyRequestContext,\n    LogEvent,\n    LogLevel,\n    graceful,\n)\nfrom gracy.exceptions import GracyUserDefinedException\n\nretry = GracefulRetry(\n    delay=1,\n    max_attempts=3,\n    delay_modifier=1.5,\n    retry_on=None,\n    log_before=LogEvent(LogLevel.WARNING),\n    log_after=LogEvent(LogLevel.WARNING),\n    log_exhausted=LogEvent(LogLevel.CRITICAL),\n    behavior=\"pass\",\n)\n\n\nclass PokemonNotFound(GracyUserDefinedException):\n    BASE_MESSAGE = \"Unable to find a pokemon with the name [{NAME}] at {URL} due to {STATUS} status\"\n\n    def _format_message(\n        self, request_context: GracyRequestContext, response: httpx.Response\n    ) -> str:\n        format_args = self._build_default_args()\n        name = request_context.endpoint_args.get(\"NAME\", \"Unknown\")\n        return self.BASE_MESSAGE.format(NAME=name, **format_args)\n\n\nclass ServerIsOutError(Exception):\n    pass\n\n\nclass PokeApiEndpoint(BaseEndpoint):\n    GET_POKEMON = \"/pokemon/{NAME}\"\n    GET_GENERATION = \"/generation/{ID}\"\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n\n    @graceful(\n        strict_status_code={HTTPStatus.OK},\n        retry=retry,\n        log_request=LogEvent(LogLevel.WARNING),\n        log_errors=LogEvent(\n            LogLevel.ERROR,\n            lambda r: \"Request failed with {STATUS}\"\n            f\" and it was {'' if r.is_redirect else 'NOT'} redirected\"\n            if r\n            else \"\",\n        ),\n        parser={\n            \"default\": lambda r: r.json()[\"name\"],\n            HTTPStatus.NOT_FOUND: PokemonNotFound,\n            HTTPStatus.INTERNAL_SERVER_ERROR: ServerIsOutError,\n        },\n    )\n    async def get_pokemon(self, name: str):\n        self.get\n\n        return await self.get[t.Optional[str]](\n            PokeApiEndpoint.GET_POKEMON, {\"NAME\": name}\n        )\n\n    async def get_generation(self, gen: int):\n        return await self.get(PokeApiEndpoint.GET_GENERATION, {\"ID\": str(gen)})\n\n\npokeapi = GracefulPokeAPI()\npokeapi_two = GracefulPokeAPI()\n\n\nasync def main():\n    try:\n        p1 = await pokeapi.get_pokemon(\"pikachu\")\n\n        try:\n            p2 = await pokeapi_two.get_pokemon(\"doesnt-exist\")\n        except PokemonNotFound as ex:\n            p2 = str(ex)\n\n        await pokeapi.get_generation(1)\n\n        print(\"P1: result of get_pokemon:\", p1)\n        print(\"P2: result of get_pokemon:\", p2)\n\n    finally:\n        pokeapi.report_status(\"list\")\n\n\nasyncio.run(main())\n"
  },
  {
    "path": "examples/pokeapi_limit_concurrency.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport logging\nimport time\nfrom datetime import timedelta\nfrom http import HTTPStatus\n\nfrom rich import print\nfrom rich.logging import RichHandler\n\nlogging.basicConfig(\n    level=logging.INFO,\n    format=\"%(message)s\",\n    datefmt=\"[%X]\",\n    handlers=[RichHandler()],\n)\n\nfrom gracy import (  # noqa: E402\n    BaseEndpoint,\n    ConcurrentRequestLimit,\n    GracefulRetry,\n    Gracy,\n    GracyConfig,\n    LogEvent,\n    LogLevel,\n    graceful,\n)\n\nCONCURRENCY = (\n    ConcurrentRequestLimit(\n        2,\n        limit_per_uurl=False,\n        log_limit_reached=LogEvent(\n            LogLevel.ERROR,\n            custom_message=\"{URL} hit {CONCURRENT_REQUESTS} ongoing concurrent request\",\n        ),\n        log_limit_freed=LogEvent(LogLevel.INFO, \"{URL} is free to request\"),\n    ),\n)\n\nRETRY = GracefulRetry(\n    delay=0,  # Force throttling to work\n    max_attempts=3,\n    retry_on=None,\n    log_after=LogEvent(LogLevel.INFO),\n    log_exhausted=LogEvent(LogLevel.ERROR),\n    behavior=\"pass\",\n)\n\n\nclass PokeApiEndpoint(BaseEndpoint):\n    GET_POKEMON = \"/pokemon/{NAME}\"\n    GET_GENERATION = \"/generation/{ID}\"\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n        SETTINGS = GracyConfig(\n            strict_status_code={HTTPStatus.OK},\n            retry=RETRY,\n            concurrent_requests=CONCURRENCY,\n            parser={\n                \"default\": lambda r: r.json(),\n                HTTPStatus.NOT_FOUND: None,\n            },\n        )\n\n    @graceful(\n        parser={\"default\": lambda r: r.json()[\"order\"], HTTPStatus.NOT_FOUND: None}\n    )\n    async def get_pokemon(self, name: str):\n        await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    async def get_generation(self, gen: int):\n        return await self.get(PokeApiEndpoint.GET_GENERATION, {\"ID\": str(gen)})\n\n    @graceful(parser={\"default\": lambda r: r})\n    async def slow_req(self, s: int):\n        await self.get(\"https://httpbin.org/delay/{DELAY}\", dict(DELAY=str(s)))\n\n\npokeapi = GracefulPokeAPI()\n\n\nasync def main():\n    pokemon_names = [\n        \"bulbasaur\",\n        \"charmander\",\n        \"squirtle\",\n        \"pikachu\",\n        \"jigglypuff\",\n        \"mewtwo\",\n        \"gyarados\",\n        \"dragonite\",\n        \"mew\",\n        \"chikorita\",\n        \"cyndaquil\",\n        \"totodile\",\n        \"pichu\",\n        \"togepi\",\n        \"ampharos\",\n        \"typhlosion\",\n        \"feraligatr\",\n        \"espeon\",\n        \"umbreon\",\n        \"lugia\",\n        \"ho-oh\",\n        \"treecko\",\n        \"torchic\",\n        \"mudkip\",\n        \"gardevoir\",\n        \"sceptile\",\n        \"blaziken\",\n        \"swampert\",\n        \"rayquaza\",\n        \"latias\",\n        \"latios\",\n        \"lucario\",\n        \"garchomp\",\n        \"darkrai\",\n        \"giratina\",  # (1) this fails, so good to test retry\n        \"arceus\",\n        \"snivy\",\n        \"tepig\",\n        \"oshawott\",\n        \"zekrom\",\n        \"reshiram\",\n        \"victini\",\n        \"chespin\",\n        \"fennekin\",\n        \"froakie\",\n        \"xerneas\",\n        \"yveltal\",\n        \"zygarde\",  # (2) this fails, so good to test retry\n        \"decidueye\",\n        \"incineroar\",\n    ]\n    # pokemon_names = pokemon_names[:10]\n\n    try:\n        start = time.time()\n\n        pokemon_reqs = [\n            asyncio.create_task(pokeapi.get_pokemon(name))\n            for name in pokemon_names[:10]\n        ]\n\n        slow_reqs = [asyncio.create_task(pokeapi.slow_req(s)) for s in range(3)]\n\n        pokemon_reqs += [\n            asyncio.create_task(pokeapi.get_pokemon(name))\n            for name in pokemon_names[10:20]\n        ]\n\n        slow_reqs += [asyncio.create_task(pokeapi.slow_req(s)) for s in range(3)]\n\n        pokemon_reqs += [\n            asyncio.create_task(pokeapi.get_pokemon(name))\n            for name in pokemon_names[20:]\n        ]\n\n        gen_reqs = [\n            asyncio.create_task(pokeapi.get_generation(gen)) for gen in range(1, 4)\n        ]\n\n        await asyncio.gather(*pokemon_reqs, *gen_reqs, *slow_reqs)\n\n        await pokeapi.get_pokemon(\"hitmonchan\")\n\n        elapsed = time.time() - start\n        print(f\"All requests took {timedelta(seconds=elapsed)}s to finish\")\n\n    finally:\n        plotly = pokeapi.report_status(\"plotly\")\n        plotly.show()\n\n\nasyncio.run(main())\n"
  },
  {
    "path": "examples/pokeapi_namespaces.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport typing as t\nfrom http import HTTPStatus\n\nfrom gracy import (\n    BaseEndpoint,\n    Gracy,\n    GracyConfig,\n    GracyNamespace,\n    LogEvent,\n    LogLevel,\n)\nfrom rich import print\n\nRESP_TYPE = t.Union[t.Dict[str, t.Any], None]\n\n\nclass PokeApiEndpoint(BaseEndpoint):\n    BERRY = \"/berry/{KEY}\"\n    BERRY_FLAVOR = \"/berry-flavor/{KEY}\"\n    BERRY_FIRMNESS = \"/berry-firmness/{KEY}\"\n\n    POKEMON = \"/pokemon/{KEY}\"\n    POKEMON_COLOR = \"/pokemon-color/{KEY}\"\n    POKEMON_FORM = \"/pokemon-form/{KEY}\"\n\n\nclass PokeApiBerryNamespace(GracyNamespace[PokeApiEndpoint]):\n    async def get_this(self, name_or_id: t.Union[str, int]):\n        return await self.get[RESP_TYPE](\n            PokeApiEndpoint.BERRY, dict(KEY=str(name_or_id))\n        )\n\n    async def get_flavor(self, name_or_id: t.Union[str, int]):\n        return await self.get[RESP_TYPE](\n            PokeApiEndpoint.BERRY_FLAVOR, dict(KEY=str(name_or_id))\n        )\n\n    async def get_firmness(self, name_or_id: t.Union[str, int]):\n        return await self.get[RESP_TYPE](\n            PokeApiEndpoint.BERRY_FIRMNESS, dict(KEY=str(name_or_id))\n        )\n\n\nclass PokeApiPokemonNamespace(GracyNamespace[PokeApiEndpoint]):\n    async def get_this(self, name_or_id: t.Union[str, int]):\n        return await self.get[RESP_TYPE](\n            PokeApiEndpoint.POKEMON, dict(KEY=str(name_or_id))\n        )\n\n    async def get_color(self, name_or_id: t.Union[str, int]):\n        return await self.get[RESP_TYPE](\n            PokeApiEndpoint.POKEMON_COLOR, dict(KEY=str(name_or_id))\n        )\n\n    async def get_form(self, name_or_id: t.Union[str, int]):\n        return await self.get[RESP_TYPE](\n            PokeApiEndpoint.POKEMON_FORM, dict(KEY=str(name_or_id))\n        )\n\n\nclass PokeApi(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n        REQUEST_TIMEOUT = 5.0\n        SETTINGS = GracyConfig(\n            parser={\n                HTTPStatus.OK: lambda resp: resp.json(),\n                HTTPStatus.NOT_FOUND: None,\n            },\n            allowed_status_code=HTTPStatus.NOT_FOUND,\n            log_errors=LogEvent(LogLevel.ERROR),\n        )\n\n    berry: PokeApiBerryNamespace\n    pokemon: PokeApiPokemonNamespace\n\n\nasync def main():\n    api = PokeApi()\n\n    berry = api.berry.get_this(\"cheri\")\n    berry_flavor = api.berry.get_flavor(\"spicy\")\n    pikachu = api.pokemon.get_this(\"pikachu\")\n    black = api.pokemon.get_color(\"black\")\n\n    results = await asyncio.gather(berry, berry_flavor, pikachu, black)\n\n    for content in results:\n        print(content)\n\n    api.report_status(\"rich\")\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/pokeapi_replay.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport httpx\nfrom http import HTTPStatus\n\nfrom gracy import (\n    BaseEndpoint,\n    GracefulRetry,\n    Gracy,\n    GracyReplay,\n    GracyRequestContext,\n    LogEvent,\n    LogLevel,\n    ReplayLogEvent,\n    graceful,\n)\nfrom gracy.exceptions import GracyUserDefinedException\nfrom gracy.replays.storages.sqlite import SQLiteReplayStorage\n\nretry = GracefulRetry(\n    delay=1,\n    max_attempts=3,\n    delay_modifier=1.5,\n    retry_on=None,\n    log_before=LogEvent(LogLevel.WARNING),\n    log_after=LogEvent(LogLevel.WARNING),\n    log_exhausted=LogEvent(LogLevel.CRITICAL),\n    behavior=\"pass\",\n)\n\nrecord_mode = GracyReplay(\n    \"record\",\n    SQLiteReplayStorage(\"pokeapi.sqlite3\"),\n)\nreplay_mode = GracyReplay(\n    \"replay\",\n    SQLiteReplayStorage(\"pokeapi.sqlite3\"),\n    log_replay=ReplayLogEvent(LogLevel.WARNING, frequency=1),\n)\n\n\nclass PokemonNotFound(GracyUserDefinedException):\n    BASE_MESSAGE = \"Unable to find a pokemon with the name [{NAME}] at {URL} due to {STATUS} status\"\n\n    def _format_message(\n        self, request_context: GracyRequestContext, response: httpx.Response\n    ) -> str:\n        format_args = self._build_default_args()\n        name = request_context.endpoint_args.get(\"NAME\", \"Unknown\")\n        return self.BASE_MESSAGE.format(NAME=name, **format_args)\n\n\nclass PokeApiEndpoint(BaseEndpoint):\n    GET_POKEMON = \"/pokemon/{NAME}\"\n    GET_GENERATION = \"/generation/{ID}\"\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n\n    @graceful(\n        strict_status_code={HTTPStatus.OK},\n        retry=retry,\n        log_errors=LogEvent(\n            LogLevel.ERROR,\n            lambda r: \"Request failed with {STATUS}\"\n            f\" and it was {'' if r.is_redirect else 'NOT'} redirected\"\n            if r\n            else \"\",\n        ),\n        log_response=LogEvent(LogLevel.INFO),\n        parser={\n            \"default\": lambda r: r.json()[\"name\"],\n            HTTPStatus.NOT_FOUND: PokemonNotFound,\n            HTTPStatus.INTERNAL_SERVER_ERROR: PokemonNotFound,\n        },\n    )\n    async def get_pokemon(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    async def get_generation(self, gen: int):\n        return await self.get(PokeApiEndpoint.GET_GENERATION, {\"ID\": str(gen)})\n\n\nasync def main(replay_mode: GracyReplay):\n    pokeapi = GracefulPokeAPI(replay_mode)\n    poke_names = {\"pikachu\", \"elekid\", \"charmander\", \"blaziken\", \"hitmonchan\"}\n\n    try:\n        get_pokemons = [\n            asyncio.create_task(pokeapi.get_pokemon(name)) for name in poke_names\n        ]\n        get_gens = [\n            asyncio.create_task(pokeapi.get_generation(gen_id))\n            for gen_id in range(1, 3)\n        ]\n\n        await asyncio.gather(*(get_pokemons + get_gens))\n\n    finally:\n        pokeapi.report_status(\"rich\")\n        print(\"-\" * 100)\n        pokeapi.report_status(\"list\")\n        print(\"-\" * 100)\n        pokeapi.report_status(\"logger\")\n\n\nasyncio.run(main(replay_mode))\n"
  },
  {
    "path": "examples/pokeapi_replay_mongo.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport httpx\nfrom http import HTTPStatus\n\nfrom gracy import (\n    BaseEndpoint,\n    GracefulRetry,\n    Gracy,\n    GracyReplay,\n    GracyRequestContext,\n    LogEvent,\n    LogLevel,\n    graceful,\n)\nfrom gracy.exceptions import GracyUserDefinedException\nfrom gracy.replays.storages.pymongo import MongoCredentials, MongoReplayStorage\n\nretry = GracefulRetry(\n    delay=1,\n    max_attempts=3,\n    delay_modifier=1.5,\n    retry_on=None,\n    log_before=LogEvent(LogLevel.WARNING),\n    log_after=LogEvent(LogLevel.WARNING),\n    log_exhausted=LogEvent(LogLevel.CRITICAL),\n    behavior=\"pass\",\n)\n\nmongo_container = MongoCredentials(\n    host=\"localhost\", username=\"root\", password=\"example\"\n)\nrecord_mode = GracyReplay(\"record\", MongoReplayStorage(mongo_container))\nreplay_mode = GracyReplay(\"replay\", MongoReplayStorage(mongo_container))\n\n\nclass PokemonNotFound(GracyUserDefinedException):\n    BASE_MESSAGE = \"Unable to find a pokemon with the name [{NAME}] at {URL} due to {STATUS} status\"\n\n    def _format_message(\n        self, request_context: GracyRequestContext, response: httpx.Response\n    ) -> str:\n        format_args = self._build_default_args()\n        name = request_context.endpoint_args.get(\"NAME\", \"Unknown\")\n        return self.BASE_MESSAGE.format(NAME=name, **format_args)\n\n\nclass PokeApiEndpoint(BaseEndpoint):\n    GET_POKEMON = \"/pokemon/{NAME}\"\n    GET_GENERATION = \"/generation/{ID}\"\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n\n    @graceful(\n        strict_status_code={HTTPStatus.OK},\n        retry=retry,\n        log_errors=LogEvent(\n            LogLevel.ERROR,\n            lambda r: \"Request failed with {STATUS}\"\n            f\" and it was {'' if r.is_redirect else 'NOT'} redirected\"\n            if r\n            else \"\",\n        ),\n        parser={\n            \"default\": lambda r: r.json()[\"name\"],\n            HTTPStatus.NOT_FOUND: PokemonNotFound,\n            HTTPStatus.INTERNAL_SERVER_ERROR: PokemonNotFound,\n        },\n    )\n    async def get_pokemon(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    async def get_generation(self, gen: int):\n        return await self.get(PokeApiEndpoint.GET_GENERATION, {\"ID\": str(gen)})\n\n\nasync def main(replay_mode: GracyReplay):\n    pokeapi = GracefulPokeAPI(replay_mode)\n    poke_names = {\"pikachu\", \"elekid\", \"charmander\", \"blaziken\", \"hitmonchan\"}\n\n    try:\n        get_pokemons = [\n            asyncio.create_task(pokeapi.get_pokemon(name)) for name in poke_names\n        ]\n        get_gens = [\n            asyncio.create_task(pokeapi.get_generation(gen_id))\n            for gen_id in range(1, 3)\n        ]\n\n        await asyncio.gather(*(get_pokemons + get_gens))\n\n    finally:\n        pokeapi.report_status(\"rich\")\n        print(\"-\" * 100)\n        pokeapi.report_status(\"list\")\n        print(\"-\" * 100)\n        pokeapi.report_status(\"logger\")\n\n\nasyncio.run(main(replay_mode))\n"
  },
  {
    "path": "examples/pokeapi_retry.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom http import HTTPStatus\n\nfrom gracy import (\n    BaseEndpoint,\n    GracefulRetry,\n    Gracy,\n    GracyReplay,\n    LogEvent,\n    LogLevel,\n    graceful,\n)\nfrom gracy.replays.storages.sqlite import SQLiteReplayStorage\n\nretry = GracefulRetry(\n    delay=1,\n    max_attempts=3,\n    delay_modifier=1.2,\n    retry_on=None,\n    log_before=LogEvent(LogLevel.WARNING),\n    log_after=LogEvent(LogLevel.WARNING),\n    log_exhausted=LogEvent(LogLevel.CRITICAL),\n    behavior=\"pass\",\n)\n\n\nclass ServerIsOutError(Exception):\n    pass\n\n\nclass PokeApiEndpoint(BaseEndpoint):\n    GET_POKEMON = \"/pokemon/{NAME}\"\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n\n    @graceful(\n        strict_status_code={HTTPStatus.OK},\n        retry=retry,\n        log_errors=LogEvent(LogLevel.ERROR),\n        parser={\n            \"default\": lambda r: r.json()[\"name\"],\n            HTTPStatus.NOT_FOUND: None,\n            HTTPStatus.INTERNAL_SERVER_ERROR: ServerIsOutError,\n        },\n    )\n    async def get_pokemon(self, name: str):\n        return await self.get[str](PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n\nrecord = GracyReplay(\"record\", SQLiteReplayStorage(\"pokeapi.sqlite3\"))\npokeapi = GracefulPokeAPI(record)\n\n\nasync def main():\n    try:\n        p1: str | None = await pokeapi.get_pokemon(\"pikachu\")  # 1 req = 200\n        print(\"P1: result of get_pokemon:\", p1)\n\n        p2: str | None = await pokeapi.get_pokemon(\"doesnt-exist\")  # 1+3 req = 404\n        print(\"P2: result of get_pokemon:\", p2)\n\n    finally:\n        pokeapi.report_status(\"rich\")\n\n\nasyncio.run(main())\n"
  },
  {
    "path": "examples/pokeapi_throttle.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport time\nimport typing as t\nfrom datetime import timedelta\nfrom http import HTTPStatus\n\nfrom gracy import (\n    BaseEndpoint,\n    GracefulRetry,\n    GracefulThrottle,\n    Gracy,\n    GracyConfig,\n    LogEvent,\n    LogLevel,\n    ThrottleRule,\n    graceful,\n)\nfrom rich import print\n\nRETRY = GracefulRetry(\n    delay=0,  # Force throttling to work\n    max_attempts=3,\n    retry_on=None,\n    log_after=LogEvent(LogLevel.WARNING),\n    log_exhausted=LogEvent(LogLevel.CRITICAL),\n    behavior=\"pass\",\n)\n\nTHROTTLE_RULE = ThrottleRule(r\".*\", 4, timedelta(seconds=2))\n\n\nclass PokeApiEndpoint(BaseEndpoint):\n    GET_POKEMON = \"/pokemon/{NAME}\"\n    GET_GENERATION = \"/generation/{ID}\"\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n        SETTINGS = GracyConfig(\n            strict_status_code={HTTPStatus.OK},\n            retry=RETRY,\n            parser={\n                \"default\": lambda r: r.json(),\n                HTTPStatus.NOT_FOUND: None,\n            },\n            throttling=GracefulThrottle(\n                rules=THROTTLE_RULE,\n                log_limit_reached=LogEvent(LogLevel.ERROR),\n                log_wait_over=LogEvent(LogLevel.WARNING),\n            ),\n        )\n\n    @graceful(\n        parser={\"default\": lambda r: r.json()[\"order\"], HTTPStatus.NOT_FOUND: None}\n    )\n    async def get_pokemon(self, name: str):\n        val = t.cast(\n            t.Optional[str], await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n        )\n\n        if val:\n            print(f\"{name} is #{val} in the pokedex\")\n        else:\n            print(f\"{name} was not found\")\n\n    async def get_generation(self, gen: int):\n        return await self.get(PokeApiEndpoint.GET_GENERATION, {\"ID\": str(gen)})\n\n\npokeapi = GracefulPokeAPI()\n\n\nasync def main():\n    pokemon_names = [\n        \"bulbasaur\",\n        \"charmander\",\n        \"squirtle\",\n        \"pikachu\",\n        \"jigglypuff\",\n        \"mewtwo\",\n        \"gyarados\",\n        \"dragonite\",\n        \"mew\",\n        \"chikorita\",\n        \"cyndaquil\",\n        \"totodile\",\n        \"pichu\",\n        \"togepi\",\n        \"ampharos\",\n        \"typhlosion\",\n        \"feraligatr\",\n        \"espeon\",\n        \"umbreon\",\n        \"lugia\",\n        \"ho-oh\",\n        \"treecko\",\n        \"torchic\",\n        \"mudkip\",\n        \"gardevoir\",\n        \"sceptile\",\n        \"blaziken\",\n        \"swampert\",\n        \"rayquaza\",\n        \"latias\",\n        \"latios\",\n        \"lucario\",\n        \"garchomp\",\n        \"darkrai\",\n        \"giratina\",  # (1) this fails, so good to test retry\n        \"arceus\",\n        \"snivy\",\n        \"tepig\",\n        \"oshawott\",\n        \"zekrom\",\n        \"reshiram\",\n        \"victini\",\n        \"chespin\",\n        \"fennekin\",\n        \"froakie\",\n        \"xerneas\",\n        \"yveltal\",\n        \"zygarde\",  # (2) this fails, so good to test retry\n        \"decidueye\",\n        \"incineroar\",\n    ]\n    # pokemon_names = pokemon_names[:10]\n    print(\n        f\"Will query {len(pokemon_names)} pokemons concurrently - {str(THROTTLE_RULE)}\"\n    )\n\n    try:\n        start = time.time()\n\n        pokemon_reqs = [\n            asyncio.create_task(pokeapi.get_pokemon(name)) for name in pokemon_names\n        ]\n        gen_reqs = [\n            asyncio.create_task(pokeapi.get_generation(gen)) for gen in range(1, 4)\n        ]\n\n        await asyncio.gather(*pokemon_reqs, *gen_reqs)\n        elapsed = time.time() - start\n        print(f\"All requests took {timedelta(seconds=elapsed)}s to finish\")\n\n    finally:\n        pokeapi.report_status(\"rich\")\n        pokeapi.report_status(\"list\")\n        pokeapi._throttle_controller.debug_print()  # type: ignore\n\n\nasyncio.run(main())\n"
  },
  {
    "path": "examples/pokestarwarsapi.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom http import HTTPStatus\n\nfrom gracy import BaseEndpoint, Gracy, LogEvent, LogLevel, graceful\n\n\nclass PokeApiEndpoint(BaseEndpoint):\n    GET_POKEMON = \"/pokemon/{NAME}\"\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n\n    @graceful(\n        strict_status_code={HTTPStatus.OK},\n        log_request=LogEvent(LogLevel.INFO),\n        parser={\n            \"default\": lambda r: r.json()[\"name\"],\n            HTTPStatus.NOT_FOUND: None,\n        },\n    )\n    async def get_pokemon(self, name: str):\n        return await self.get[str](PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n\nclass StarWarsAPI(Gracy[str]):\n    class Config:\n        BASE_URL = \"https://swapi.dev/api/\"\n\n    @graceful(\n        strict_status_code=HTTPStatus.OK,\n        log_request=LogEvent(LogLevel.INFO),\n        parser={\"default\": lambda r: r.json()[\"name\"]},\n    )\n    async def get_person(self, person_id: int):\n        return await self.get[str](\"people/{PERSON_ID}\", {\"PERSON_ID\": str(person_id)})\n\n\npokeapi = GracefulPokeAPI()\nswapi = StarWarsAPI()\n\n\nasync def main():\n    try:\n        pk: str | None = await pokeapi.get_pokemon(\"pikachu\")\n        sw: str = await swapi.get_person(1)\n\n        print(\"PK: result of get_pokemon:\", pk)\n        print(\"SW: result of get_person:\", sw)\n\n    finally:\n        pokeapi.report_status(\"rich\")\n\n\nasyncio.run(main())\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.poetry]\nname = \"gracy\"\nversion = \"1.34.0\"\ndescription = \"Gracefully manage your API interactions\"\nauthors = [\"Guilherme Latrova <hello@guilatrova.dev>\"]\nlicense = \"MIT\"\nkeywords = [\"api\", \"throttling\", \"http\", \"https\", \"async\", \"retry\"]\nreadme = \"README.md\"\nhomepage = \"https://github.com/guilatrova/gracy\"\nrepository = \"https://github.com/guilatrova/gracy\"\ninclude = [\"LICENSE\", \"py.typed\"]\nclassifiers = [\n    \"Development Status :: 4 - Beta\",\n    \"Environment :: Web Environment\",\n    \"Framework :: AsyncIO\",\n    \"Intended Audience :: Developers\",\n    \"License :: OSI Approved :: BSD License\",\n    \"Operating System :: OS Independent\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3 :: Only\",\n    \"Programming Language :: Python :: 3.8\",\n    \"Programming Language :: Python :: 3.9\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Topic :: Internet :: WWW/HTTP\",\n]\npackages = [{ include = \"gracy\", from = \"src\" }]\n\n[tool.poetry.urls]\n\"Changelog\" = \"https://github.com/guilatrova/gracy/blob/main/CHANGELOG.md\"\n\n\n[tool.semantic_release]\nversion_variable = [\"src/gracy/__init__.py:__version__\"]\nversion_toml = [\"pyproject.toml:tool.poetry.version\"]\nbranch = \"main\"\nupload_to_pypi = true\nupload_to_release = true\nbuild_command = \"pip install poetry && poetry build\"\n\n[tool.poetry.dependencies]\npython = \">=3.8.1,<4.0\"\nhttpx = \">=0.23.0\"\nrich = { version = \"*\", optional = true }\npymongo = { version = \"*\", optional = true }\ntyping-extensions = \"^4.9.0\"\n# It should be python = \"<3.10\" if we didn't use the 'deprecated' import from PEP 702\n\n[tool.poetry.group.dev.dependencies]\npython-semantic-release = \"^7.33.0\"\npre-commit = \"^3.5.0\"\nrich = \"^13.2.0\"\npymongo = \"^4.3.3\"\npytest = \"^7.2.1\"\npytest-asyncio = \"^0.20.3\"\nruff = \"^0.1.6\"\npyright = \"^1.1.351\"\n\n[tool.poetry.extras]\nrich = [\"rich\"]\npymongo = [\"pymongo\"]\nplotly = [\"plotly\", \"pandas\"]\n\n[tool.ruff.lint.isort]\nextra-standard-library = [\"pytest\", \"httpx\"]\nrequired-imports = [\"from __future__ import annotations\"]\n\n# https://microsoft.github.io/pyright/#/configuration\n[tool.pyright]\ninclude = [\"src\"]\npythonVersion = \"3.8\"\npythonPlatform = \"All\"\nreportMissingImports = \"warning\"\nreportIncompatibleVariableOverride = \"none\"\n\n[tool.pytest.ini_options]\nasyncio_mode = \"auto\"\ntestpaths = \"src/tests\"\n\n[build-system]\nrequires = [\"poetry-core>=1.0.0\"]\nbuild-backend = \"poetry.core.masonry.api\"\n"
  },
  {
    "path": "src/gracy/__init__.py",
    "content": "\"\"\"Gracefully manage your API interactions\"\"\"\nfrom __future__ import annotations\n\nimport logging\n\nfrom . import common_hooks, exceptions, replays\nfrom ._core import Gracy, GracyNamespace, graceful, graceful_generator\nfrom ._models import (\n    DEFAULT_CONFIG,\n    BaseEndpoint,\n    ConcurrentRequestLimit,\n    GracefulRetry,\n    GracefulRetryState,\n    GracefulThrottle,\n    GracefulValidator,\n    GracyConfig,\n    GracyRequestContext,\n    LogEvent,\n    LogLevel,\n    OverrideRetryOn,\n    ThrottleRule,\n)\nfrom ._paginator import GracyOffsetPaginator, GracyPaginator\nfrom ._reports._models import GracyAggregatedRequest, GracyAggregatedTotal, GracyReport\nfrom ._types import generated_parsed_response, parsed_response\nfrom .replays.storages._base import GracyReplay, GracyReplayStorage, ReplayLogEvent\n\nlogging.basicConfig(level=logging.INFO, format=\"%(asctime)s %(message)s\")\n\n__version__ = \"1.34.0\"\n\n__all__ = [\n    \"exceptions\",\n    # Core\n    \"Gracy\",\n    \"GracyNamespace\",\n    \"graceful\",\n    \"graceful_generator\",\n    # Paginatior\n    \"GracyPaginator\",\n    \"GracyOffsetPaginator\",\n    # Models\n    \"BaseEndpoint\",\n    \"GracefulRetry\",\n    \"OverrideRetryOn\",\n    \"GracefulRetryState\",\n    \"GracefulValidator\",\n    \"GracyRequestContext\",\n    \"LogEvent\",\n    \"LogLevel\",\n    \"GracefulThrottle\",\n    \"ThrottleRule\",\n    \"GracyConfig\",\n    \"DEFAULT_CONFIG\",\n    \"ConcurrentRequestLimit\",\n    # Replays\n    \"replays\",\n    \"GracyReplay\",\n    \"GracyReplayStorage\",\n    \"ReplayLogEvent\",\n    # Reports\n    \"GracyReport\",\n    \"GracyAggregatedTotal\",\n    \"GracyAggregatedRequest\",\n    # Hooks\n    \"common_hooks\",\n    # Types\n    \"parsed_response\",\n    \"generated_parsed_response\",\n]\n"
  },
  {
    "path": "src/gracy/_configs.py",
    "content": "from __future__ import annotations\n\nfrom contextlib import contextmanager\nfrom contextvars import ContextVar\n\nfrom ._models import GracyConfig\n\ncustom_config_context: ContextVar[GracyConfig | None] = ContextVar(\n    \"gracy_context\", default=None\n)\nwithin_hook_context: ContextVar[bool] = ContextVar(\"within_hook_context\", default=False)\n\n\n@contextmanager\ndef custom_gracy_config(config: GracyConfig):\n    token = custom_config_context.set(config)\n\n    try:\n        yield\n    finally:\n        try:\n            custom_config_context.reset(token)\n        except Exception:\n            pass  # Best effort\n\n\n@contextmanager\ndef within_hook():\n    token = within_hook_context.set(True)\n\n    try:\n        yield\n    finally:\n        within_hook_context.reset(token)\n"
  },
  {
    "path": "src/gracy/_core.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport httpx\nimport inspect\nimport logging\nimport sys\nimport typing as t\nimport weakref\nfrom asyncio import sleep\nfrom contextlib import asynccontextmanager\nfrom http import HTTPStatus\nfrom time import time\n\nfrom gracy.replays._wrappers import record_mode, replay_mode, smart_replay_mode\n\nfrom ._configs import (\n    custom_config_context,\n    custom_gracy_config,\n    within_hook,\n    within_hook_context,\n)\nfrom ._general import extract_request_kwargs\nfrom ._loggers import (\n    DefaultLogMessage,\n    process_log_after_request,\n    process_log_before_request,\n    process_log_concurrency_freed,\n    process_log_concurrency_limit,\n    process_log_retry,\n    process_log_throttle,\n)\nfrom ._models import (\n    CONCURRENT_REQUEST_TYPE,\n    DEFAULT_CONFIG,\n    LOG_EVENT_TYPE,\n    PARSER_TYPE,\n    THROTTLE_LOCKER,\n    UNSET_VALUE,\n    ConcurrentRequestLimit,\n    Endpoint,\n    GracefulRequest,\n    GracefulRetry,\n    GracefulRetryState,\n    GracefulValidator,\n    GracyConfig,\n    GracyRequestContext,\n    LogEvent,\n    ThrottleController,\n    ThrottleRule,\n    Unset,\n)\nfrom ._reports._builders import ReportBuilder\nfrom ._reports._printers import PRINTERS, print_report\nfrom ._validators import AllowedStatusValidator, DefaultValidator, StrictStatusValidator\nfrom .exceptions import GracyParseFailed, GracyRequestFailed\nfrom .replays.storages._base import GracyReplay\n\nif sys.version_info >= (3, 10):\n    from typing import ParamSpec\nelse:\n    from typing_extensions import ParamSpec\n\n\nlogger = logging.getLogger(\"gracy\")\n\n\nANY_COROUTINE = t.Coroutine[t.Any, t.Any, t.Any]\n\nP = ParamSpec(\"P\")\nHTTP_T = t.TypeVar(\"HTTP_T\")\nGRACEFUL_T = t.TypeVar(\"GRACEFUL_T\", bound=ANY_COROUTINE)\nGRACEFUL_GEN_T = t.TypeVar(\"GRACEFUL_GEN_T\", bound=t.AsyncGenerator[t.Any, t.Any])\nBEFORE_HOOK_TYPE = t.Callable[[GracyRequestContext], t.Awaitable[None]]\nAFTER_HOOK_TYPE = t.Callable[\n    [\n        GracyRequestContext,\n        t.Union[httpx.Response, Exception],\n        t.Optional[GracefulRetryState],\n    ],\n    t.Awaitable[None],\n]\n\n\nasync def _gracefully_throttle(\n    report: ReportBuilder,\n    controller: ThrottleController,\n    request_context: GracyRequestContext,\n):\n    if isinstance(request_context.active_config.throttling, Unset):\n        return\n\n    if throttling := request_context.active_config.throttling:\n        has_been_throttled = True\n\n        while has_been_throttled:\n            wait_per_rule: list[tuple[ThrottleRule, float]] = [\n                (rule, wait_time)\n                for rule in throttling.rules\n                if (wait_time := rule.calculate_await_time(controller)) > 0.0\n            ]\n\n            if wait_per_rule:\n                rule: ThrottleRule\n                await_time: float\n                rule, await_time = max(wait_per_rule, key=lambda x: x[1])  # type: ignore\n                if THROTTLE_LOCKER.is_rule_throttled(rule):\n                    report.throttled(request_context)\n                    await asyncio.sleep(await_time)\n                    continue\n\n                with THROTTLE_LOCKER.lock_rule(rule):\n                    if throttling.log_limit_reached:\n                        process_log_throttle(\n                            throttling.log_limit_reached,\n                            DefaultLogMessage.THROTTLE_HIT,\n                            await_time,\n                            rule,\n                            request_context,\n                        )\n\n                    report.throttled(request_context)\n                    await asyncio.sleep(await_time)\n\n                    if throttling.log_wait_over:\n                        process_log_throttle(\n                            throttling.log_wait_over,\n                            DefaultLogMessage.THROTTLE_DONE,\n                            await_time,\n                            rule,\n                            request_context,\n                        )\n            else:\n                has_been_throttled = False\n\n\nasync def _gracefully_retry(\n    report: ReportBuilder,\n    throttle_controller: ThrottleController,\n    last_response: t.Optional[httpx.Response],\n    last_err: t.Optional[Exception],\n    before_hook: BEFORE_HOOK_TYPE,\n    after_hook: AFTER_HOOK_TYPE,\n    request: GracefulRequest,\n    request_context: GracyRequestContext,\n    validators: t.List[GracefulValidator],\n) -> GracefulRetryState:\n    config = request_context.active_config\n    retry = t.cast(GracefulRetry, config.retry)\n    state = retry.create_state(last_response, last_err)\n\n    response = last_response\n    resulting_exc: t.Optional[Exception] = None\n\n    failing = True\n    while failing:\n        state.increment(response)\n        if state.cant_retry:\n            break\n\n        if retry.log_before:\n            process_log_retry(\n                retry.log_before, DefaultLogMessage.RETRY_BEFORE, request_context, state\n            )\n\n        await sleep(state.delay)\n        await _gracefully_throttle(report, throttle_controller, request_context)\n        throttle_controller.init_request(request_context)\n\n        start = 0\n        try:\n            await before_hook(request_context)\n            start = time()\n            response = await request()\n\n        except Exception as request_err:\n            resulting_exc = GracyRequestFailed(request_context, request_err)\n            report.track(request_context, request_err, start)\n            await after_hook(request_context, request_err, state)\n\n        else:\n            report.track(request_context, response, start)\n            await after_hook(request_context, response, state)\n\n        finally:\n            report.retried(request_context)\n\n        if response:\n            resulting_exc = None\n\n            for validator in validators:\n                try:\n                    validator.check(response)\n                except Exception as ex:\n                    resulting_exc = ex\n                    break\n\n        state.last_response = response\n        state.last_exc = resulting_exc\n\n        # Even if all validators are passing, we check whether\n        # it should retry for cases like:\n        # e.g. Allow = 404 (so it's a success),\n        #      but Retry it up to 3 times to see whether it becomes 200\n        if config.should_retry(response, resulting_exc) is False:\n            state.success = True\n            failing = False\n\n        if retry.log_after:\n            process_log_retry(\n                retry.log_after,\n                DefaultLogMessage.RETRY_AFTER,\n                request_context,\n                state,\n                response,\n            )\n\n    if (\n        state.cant_retry\n        and config.should_retry(state.last_response, resulting_exc)\n        and retry.log_exhausted\n    ):\n        process_log_retry(\n            retry.log_exhausted,\n            DefaultLogMessage.RETRY_EXHAUSTED,\n            request_context,\n            state,\n            response,\n        )\n\n    return state\n\n\ndef _maybe_parse_result(\n    active_config: GracyConfig,\n    request_context: GracyRequestContext,\n    result: httpx.Response,\n):\n    if active_config.parser and not isinstance(active_config.parser, Unset):\n        default_fallback = active_config.parser.get(\"default\", UNSET_VALUE)\n        parse_result = active_config.parser.get(result.status_code, default_fallback)\n\n        if not isinstance(parse_result, Unset):\n            if isinstance(parse_result, type) and issubclass(parse_result, Exception):\n                raise parse_result(request_context, result)\n\n            elif callable(parse_result):\n                try:\n                    return parse_result(result)\n                except Exception as ex:\n                    raise GracyParseFailed(result) from ex\n\n            else:\n                return parse_result\n\n    return result\n\n\nasync def _gracify(\n    report: ReportBuilder,\n    throttle_controller: ThrottleController,\n    replay: t.Optional[GracyReplay],\n    before_hook: BEFORE_HOOK_TYPE,\n    after_hook: AFTER_HOOK_TYPE,\n    request: GracefulRequest,\n    request_context: GracyRequestContext,\n):\n    active_config = request_context.active_config\n\n    if isinstance(active_config.log_request, LogEvent):\n        process_log_before_request(active_config.log_request, request_context)\n\n    resulting_exc: t.Optional[Exception] = None\n\n    do_throttle = True\n    if replay and replay.disable_throttling:\n        replay_available = await replay.has_replay(request.request)\n        if replay_available:\n            do_throttle = False\n\n    if do_throttle:\n        await _gracefully_throttle(report, throttle_controller, request_context)\n        throttle_controller.init_request(request_context)\n\n    start = 0\n    try:\n        await before_hook(request_context)\n        start = time()\n        response = await request()\n\n    except Exception as request_err:\n        resulting_exc = GracyRequestFailed(request_context, request_err)\n        response = None\n        report.track(request_context, resulting_exc, start)\n        await after_hook(request_context, resulting_exc, None)\n\n    else:\n        report.track(request_context, response, start)\n        await after_hook(request_context, response, None)\n\n    if active_config.log_response and isinstance(active_config.log_response, LogEvent):\n        process_log_after_request(\n            active_config.log_response,\n            DefaultLogMessage.AFTER,\n            request_context,\n            response,\n        )\n\n    validators: list[GracefulValidator] = []\n    if active_config.strict_status_code and not isinstance(\n        active_config.strict_status_code, Unset\n    ):\n        validators.append(StrictStatusValidator(active_config.strict_status_code))\n    elif active_config.allowed_status_code and not isinstance(\n        active_config.allowed_status_code, Unset\n    ):\n        validators.append(AllowedStatusValidator(active_config.allowed_status_code))\n    else:\n        validators.append(DefaultValidator())\n\n    if isinstance(active_config.validators, GracefulValidator):\n        validators.append(active_config.validators)\n    elif isinstance(active_config.validators, t.Iterable):\n        validators += active_config.validators\n\n    if response:\n        for validator in validators:\n            try:\n                validator.check(response)\n            except Exception as ex:\n                resulting_exc = ex\n                break\n\n    retry_result: t.Optional[GracefulRetryState] = None\n    if active_config.should_retry(response, resulting_exc):\n        retry_result = await _gracefully_retry(\n            report,\n            throttle_controller,\n            response,\n            resulting_exc,\n            before_hook,\n            after_hook,\n            request,\n            request_context,\n            validators,\n        )\n\n        response = retry_result.last_response\n        resulting_exc = retry_result.last_exc\n\n    did_request_fail = bool(resulting_exc)\n    if did_request_fail:\n        if active_config.log_errors and isinstance(active_config.log_errors, LogEvent):\n            process_log_after_request(\n                active_config.log_errors,\n                DefaultLogMessage.ERRORS,\n                request_context,\n                response,\n            )\n\n    must_break = True\n    if (\n        isinstance(active_config.retry, GracefulRetry)\n        and active_config.retry.behavior == \"pass\"\n    ):\n        must_break = False\n\n    if resulting_exc and must_break:\n        raise resulting_exc\n\n    final_result = (\n        _maybe_parse_result(active_config, request_context, response)\n        if response\n        else None\n    )\n\n    return final_result\n\n\nclass OngoingRequestsTracker:\n    def __init__(self) -> None:\n        self._count = 0\n        self._previously_limited = False\n\n    @property\n    def count(self) -> int:\n        return self._count\n\n    @asynccontextmanager\n    async def request(\n        self,\n        context: GracyRequestContext,\n        concurrent_request: t.Optional[ConcurrentRequestLimit],\n    ):\n        has_been_limited = False\n        semaphore = None\n\n        try:\n            if concurrent_request is None:\n                self._count += 1\n                yield\n                return\n\n            semaphore = concurrent_request.get_semaphore(context)\n            has_been_limited = semaphore.locked()\n\n            await semaphore.acquire()\n            self._count += 1\n\n            if has_been_limited and self._previously_limited is False:\n                if isinstance(concurrent_request.log_limit_reached, LogEvent):\n                    process_log_concurrency_limit(\n                        concurrent_request.log_limit_reached,\n                        concurrent_request.limit,\n                        context,\n                    )\n\n            if self._previously_limited and has_been_limited is False:\n                if isinstance(concurrent_request.log_limit_freed, LogEvent):\n                    process_log_concurrency_freed(\n                        concurrent_request.log_limit_freed, context\n                    )\n\n            yield\n\n        finally:\n            if semaphore:\n                semaphore.release()\n\n            self._previously_limited = has_been_limited\n            self._count -= 1\n\n\nDISABLED_GRACY_CONFIG: t.Final = GracyConfig(\n    strict_status_code=None,\n    allowed_status_code=None,\n    validators=None,\n    retry=None,\n    log_request=None,\n    log_response=None,\n    log_errors=None,\n    parser=None,\n)\n\n\nclass Gracy(t.Generic[Endpoint]):\n    \"\"\"Helper class that provides a standard way to create an Requester using\n    inheritance.\n    \"\"\"\n\n    _reporter: ReportBuilder = ReportBuilder()\n    _throttle_controller: ThrottleController = ThrottleController()\n\n    class Config:\n        BASE_URL: str = \"\"\n        REQUEST_TIMEOUT: t.Optional[float] = None\n        SETTINGS: GracyConfig = DEFAULT_CONFIG\n\n    def __init__(\n        self,\n        replay: t.Optional[GracyReplay] = None,\n        DEBUG_ENABLED: bool = False,\n        **kwargs: t.Any,\n    ) -> None:\n        self.DEBUG_ENABLED = DEBUG_ENABLED\n        self._base_config = t.cast(\n            GracyConfig, getattr(self.Config, \"SETTINGS\", DEFAULT_CONFIG)\n        )\n        self._client = self._create_client(**kwargs)\n        self.replays = replay\n        self._ongoing_tracker = OngoingRequestsTracker()\n\n        self._post_init()\n        self._init_typed_http_methods()\n\n    def _init_typed_http_methods(self):\n        gracy_ref = weakref.ref(self)\n\n        class HTTPMethod(t.Generic[HTTP_T]):\n            def __new__(\n                cls,\n                endpoint: t.Union[Endpoint, str],\n                endpoint_args: t.Optional[t.Dict[str, str]] = None,\n                *args: t.Any,\n                **kwargs: t.Any,\n            ):\n                myself_instance = super().__new__(cls)\n                return myself_instance.execute(endpoint, endpoint_args, *args, **kwargs)\n\n            def _get_gracy_instance(self):\n                gracy_instance = gracy_ref()\n                if gracy_instance is None:\n                    raise ReferenceError(\n                        \"Gracy instance has been garbage collected - Should never happen\"\n                    )\n                return gracy_instance\n\n            async def execute(\n                self,\n                endpoint: t.Union[Endpoint, str],\n                endpoint_args: t.Optional[t.Dict[str, str]] = None,\n                *args: t.Any,\n                **kwargs: t.Any,\n            ):\n                method_name = type(self).__name__.upper()\n\n                coro = await self._get_gracy_instance()._request(\n                    method_name, endpoint, endpoint_args, *args, **kwargs\n                )\n\n                return t.cast(HTTP_T, coro)\n\n        class Get(t.Generic[HTTP_T], HTTPMethod[HTTP_T]):\n            pass\n\n        class Post(t.Generic[HTTP_T], HTTPMethod[HTTP_T]):\n            pass\n\n        class Put(t.Generic[HTTP_T], HTTPMethod[HTTP_T]):\n            pass\n\n        class Patch(t.Generic[HTTP_T], HTTPMethod[HTTP_T]):\n            pass\n\n        class Delete(t.Generic[HTTP_T], HTTPMethod[HTTP_T]):\n            pass\n\n        class Head(t.Generic[HTTP_T], HTTPMethod[HTTP_T]):\n            pass\n\n        class Options(t.Generic[HTTP_T], HTTPMethod[HTTP_T]):\n            pass\n\n        self.get = Get\n        self.post = Post\n        self.put = Put\n        self.patch = Patch\n        self.delete = Delete\n        self.head = Head\n        self.options = Options\n\n    def _post_init(self):\n        \"\"\"Initializes namespaces and replays after init\"\"\"\n\n        if self.replays:\n            self.replays.storage.prepare()\n\n        self._instantiate_namespaces()\n\n    def _instantiate_namespaces(self):\n        annotations = self.__annotations__\n        for attr_name, attr_type in annotations.items():\n            if isinstance(attr_type, str):\n                resolved_module = __import__(self.__module__, fromlist=[attr_type])\n                klass = getattr(resolved_module, attr_type, None)\n            elif inspect.isclass(attr_type):\n                klass = attr_type\n            else:\n                klass = None\n\n            if klass and issubclass(klass, GracyNamespace):\n                setattr(self, attr_name, klass(self))\n\n    @property\n    def ongoing_requests_count(self) -> int:\n        return self._ongoing_tracker.count\n\n    def _create_client(self, **kwargs: t.Any) -> httpx.AsyncClient:\n        base_url = getattr(self.Config, \"BASE_URL\", \"\")\n        request_timeout = getattr(self.Config, \"REQUEST_TIMEOUT\", None)\n        return httpx.AsyncClient(base_url=str(base_url), timeout=request_timeout)\n\n    async def _request(\n        self,\n        method: str,\n        endpoint: t.Union[Endpoint, str],\n        endpoint_args: t.Optional[t.Dict[str, str]] = None,\n        *args: t.Any,\n        **kwargs: t.Any,\n    ):\n        custom_config = custom_config_context.get()\n        active_config = self._base_config\n        if custom_config:\n            active_config = GracyConfig.merge_config(self._base_config, custom_config)\n\n        if self.DEBUG_ENABLED:\n            logger.debug(f\"Active Config for {endpoint}: {active_config}\")\n\n        request_context = GracyRequestContext(\n            method, str(self._client.base_url), endpoint, endpoint_args, active_config\n        )\n\n        httpx_request_func = self._client.request\n        if replays := self.replays:\n            if replays.mode == \"record\":\n                httpx_request_func = record_mode(replays, httpx_request_func)\n            elif replays.mode == \"replay\":\n                httpx_request_func = replay_mode(\n                    replays, self._client, httpx_request_func\n                )\n            else:\n                httpx_request_func = smart_replay_mode(\n                    replays, self._client, httpx_request_func\n                )\n\n        request_kwargs = extract_request_kwargs(kwargs)\n        request = self._client.build_request(\n            request_context.method, request_context.endpoint, **request_kwargs\n        )\n\n        graceful_request = _gracify(\n            Gracy._reporter,\n            Gracy._throttle_controller,\n            replays,\n            self._before,\n            self._after,\n            GracefulRequest(\n                request,\n                httpx_request_func,\n                request_context.method,\n                request_context.endpoint,\n                *args,\n                **kwargs,\n            ),\n            request_context,\n        )\n\n        concurrent = active_config.get_concurrent_limit(request_context)\n\n        async with self._ongoing_tracker.request(request_context, concurrent):\n            return await graceful_request\n\n    async def before(self, context: GracyRequestContext):\n        ...\n\n    async def _before(self, context: GracyRequestContext):\n        if within_hook_context.get():\n            return\n\n        with custom_gracy_config(DISABLED_GRACY_CONFIG), within_hook():\n            try:\n                await self.before(context)\n            except Exception:\n                logger.exception(\"Gracy before hook raised an unexpected exception\")\n\n    async def after(\n        self,\n        context: GracyRequestContext,\n        response_or_exc: t.Union[httpx.Response, Exception],\n        retry_state: t.Optional[GracefulRetryState],\n    ):\n        ...\n\n    async def _after(\n        self,\n        context: GracyRequestContext,\n        response_or_exc: t.Union[httpx.Response, Exception],\n        retry_state: t.Optional[GracefulRetryState],\n    ):\n        if within_hook_context.get():\n            return\n\n        with custom_gracy_config(DISABLED_GRACY_CONFIG), within_hook():\n            try:\n                await self.after(context, response_or_exc, retry_state)\n            except Exception:\n                logger.exception(\"Gracy after hook raised an unexpected exception\")\n\n    def get_report(self):\n        return self._reporter.build(self._throttle_controller, self.replays)\n\n    def report_status(self, printer: PRINTERS):\n        report = self.get_report()\n        return print_report(report, printer)\n\n    @classmethod\n    def dangerously_reset_report(cls):\n        \"\"\"\n        Doing this will reset throttling rules and metrics.\n        So be sure you know what you're doing.\n        \"\"\"\n        cls._throttle_controller = ThrottleController()\n        cls._reporter = ReportBuilder()\n\n\nclass GracyNamespace(t.Generic[Endpoint], Gracy[Endpoint]):\n    Config = None  # type: ignore\n    \"\"\"Resetted to rely on parent\"\"\"\n\n    def __init__(self, parent: Gracy[Endpoint], **kwargs: t.Any) -> None:\n        self.DEBUG_ENABLED = parent.DEBUG_ENABLED\n        self.replays = parent.replays\n        self._parent = parent\n        self._ongoing_tracker = parent._ongoing_tracker\n\n        self._init_typed_http_methods()\n        self._client = self._get_namespace_client(parent, **kwargs)\n        self._setup_namespace_config(parent)\n\n    def _get_namespace_client(\n        self, parent: Gracy[Endpoint], **kwargs: t.Any\n    ) -> httpx.AsyncClient:\n        return parent._client\n\n    def _setup_namespace_config(self, parent: Gracy[Endpoint]):\n        if self.Config is None:  # type: ignore\n            self.Config = parent.Config\n            self._base_config = parent._base_config\n\n        else:\n            parent_config = parent.Config\n\n            if not hasattr(self.Config, \"BASE_URL\"):\n                self.Config.BASE_URL = parent_config.BASE_URL\n\n            if not hasattr(self.Config, \"REQUEST_TIMEOUT\"):\n                self.Config.REQUEST_TIMEOUT = parent_config.REQUEST_TIMEOUT\n\n            if hasattr(self.Config, \"SETTINGS\"):\n                settings_config = GracyConfig.merge_config(\n                    self.Config.SETTINGS, parent_config.SETTINGS\n                )\n            else:\n                settings_config = parent_config.SETTINGS\n\n            self._base_config = settings_config\n\n        parent_settings = parent._base_config\n        parent_config = parent.Config\n\n        namespace_config = self.Config\n        namespace_config.BASE_URL = parent_config.BASE_URL\n\n        if hasattr(self.Config, \"SETTINGS\"):\n            self._base_config = GracyConfig.merge_config(\n                parent_settings, self.Config.SETTINGS\n            )\n        else:\n            self._base_config = parent_settings\n\n\ndef graceful(\n    strict_status_code: t.Union[\n        t.Iterable[HTTPStatus], HTTPStatus, None, Unset\n    ] = UNSET_VALUE,\n    allowed_status_code: t.Union[\n        t.Iterable[HTTPStatus], HTTPStatus, None, Unset\n    ] = UNSET_VALUE,\n    validators: t.Union[\n        t.Iterable[GracefulValidator], GracefulValidator, None, Unset\n    ] = UNSET_VALUE,\n    retry: t.Union[GracefulRetry, Unset, None] = UNSET_VALUE,\n    log_request: LOG_EVENT_TYPE = UNSET_VALUE,\n    log_response: LOG_EVENT_TYPE = UNSET_VALUE,\n    log_errors: LOG_EVENT_TYPE = UNSET_VALUE,\n    parser: PARSER_TYPE = UNSET_VALUE,\n    concurrent_requests: t.Union[CONCURRENT_REQUEST_TYPE, int] = UNSET_VALUE,\n):\n    concurrent_requests_config: CONCURRENT_REQUEST_TYPE\n    if isinstance(concurrent_requests, int):\n        concurrent_requests_config = ConcurrentRequestLimit(concurrent_requests)\n    else:\n        concurrent_requests_config = concurrent_requests\n\n    config = GracyConfig(\n        strict_status_code=strict_status_code,\n        allowed_status_code=allowed_status_code,\n        validators=validators,\n        retry=retry,\n        log_request=log_request,\n        log_response=log_response,\n        log_errors=log_errors,\n        parser=parser,\n        concurrent_requests=concurrent_requests_config,\n    )\n\n    def _wrapper(\n        wrapped_function: t.Callable[P, GRACEFUL_T],\n    ) -> t.Callable[P, GRACEFUL_T]:\n        async def _inner_wrapper(*args: P.args, **kwargs: P.kwargs):\n            with custom_gracy_config(config):\n                res = await wrapped_function(*args, **kwargs)\n                return res\n\n        return t.cast(t.Callable[P, GRACEFUL_T], _inner_wrapper)\n\n    return _wrapper\n\n\ndef graceful_generator(\n    strict_status_code: t.Union[\n        t.Iterable[HTTPStatus], HTTPStatus, None, Unset\n    ] = UNSET_VALUE,\n    allowed_status_code: t.Union[\n        t.Iterable[HTTPStatus], HTTPStatus, None, Unset\n    ] = UNSET_VALUE,\n    validators: t.Union[\n        t.Iterable[GracefulValidator], GracefulValidator, None, Unset\n    ] = UNSET_VALUE,\n    retry: t.Union[GracefulRetry, Unset, None] = UNSET_VALUE,\n    log_request: LOG_EVENT_TYPE = UNSET_VALUE,\n    log_response: LOG_EVENT_TYPE = UNSET_VALUE,\n    log_errors: LOG_EVENT_TYPE = UNSET_VALUE,\n    parser: PARSER_TYPE = UNSET_VALUE,\n    concurrent_requests: t.Union[CONCURRENT_REQUEST_TYPE, int] = UNSET_VALUE,\n):\n    concurrent_requests_config: CONCURRENT_REQUEST_TYPE\n    if isinstance(concurrent_requests, int):\n        concurrent_requests_config = ConcurrentRequestLimit(concurrent_requests)\n    else:\n        concurrent_requests_config = concurrent_requests\n\n    config = GracyConfig(\n        strict_status_code=strict_status_code,\n        allowed_status_code=allowed_status_code,\n        validators=validators,\n        retry=retry,\n        log_request=log_request,\n        log_response=log_response,\n        log_errors=log_errors,\n        parser=parser,\n        concurrent_requests=concurrent_requests_config,\n    )\n\n    def _wrapper(\n        wrapped_function: t.Callable[P, GRACEFUL_GEN_T],\n    ) -> t.Callable[P, GRACEFUL_GEN_T]:\n        async def _inner_wrapper(*args: P.args, **kwargs: P.kwargs):\n            with custom_gracy_config(config):\n                async for res in wrapped_function(*args, **kwargs):\n                    yield res\n\n        return t.cast(t.Callable[P, GRACEFUL_GEN_T], _inner_wrapper)\n\n    return _wrapper\n"
  },
  {
    "path": "src/gracy/_general.py",
    "content": "from __future__ import annotations\n\nimport typing as t\n\nVALID_BUILD_REQUEST_KEYS = {\n    \"content\",\n    \"data\",\n    \"files\",\n    \"json\",\n    \"params\",\n    \"headers\",\n    \"cookies\",\n    \"timeout\",\n    \"extensions\",\n}\n\"\"\"\nThere're some kwargs that are handled by httpx request, but only a few are properly handled by https build_request.\nDefined in httpx._client:322\n\"\"\"\n\n\ndef extract_request_kwargs(kwargs: dict[str, t.Any]) -> dict[str, t.Any]:\n    return {k: v for k, v in kwargs.items() if k in VALID_BUILD_REQUEST_KEYS}\n"
  },
  {
    "path": "src/gracy/_loggers.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport logging\nimport typing as t\nfrom enum import Enum\n\nfrom ._models import GracefulRetryState, GracyRequestContext, LogEvent, ThrottleRule\n\nlogger = logging.getLogger(\"gracy\")\n\n\ndef is_replay(resp: httpx.Response) -> bool:\n    return getattr(resp, \"_gracy_replayed\", False)\n\n\nclass SafeDict(t.Dict[str, str]):\n    def __missing__(self, key: str):\n        return \"{\" + key + \"}\"\n\n\nclass DefaultLogMessage(str, Enum):\n    BEFORE = \"Request on {URL} is ongoing\"\n    AFTER = \"{REPLAY}[{METHOD}] {URL} returned {STATUS}\"\n    ERRORS = \"[{METHOD}] {URL} returned a bad status ({STATUS})\"\n\n    THROTTLE_HIT = \"{URL} hit {THROTTLE_LIMIT} reqs/{THROTTLE_TIME_RANGE}\"\n    THROTTLE_DONE = \"Done waiting {THROTTLE_TIME}s to hit {URL}\"\n\n    RETRY_BEFORE = (\n        \"GracefulRetry: {URL} will wait {RETRY_DELAY}s before next attempt due to \"\n        \"{RETRY_CAUSE} ({CUR_ATTEMPT} out of {MAX_ATTEMPT})\"\n    )\n    RETRY_AFTER = (\n        \"GracefulRetry: {URL} replied {STATUS} ({CUR_ATTEMPT} out of {MAX_ATTEMPT})\"\n    )\n    RETRY_EXHAUSTED = \"GracefulRetry: {URL} exhausted the maximum attempts of {MAX_ATTEMPT} due to {RETRY_CAUSE}\"\n\n    REPLAY_RECORDED = \"Gracy Replay: Recorded {RECORDED_COUNT} requests\"\n    REPLAY_REPLAYED = \"Gracy Replay: Replayed {REPLAYED_COUNT} requests\"\n\n    CONCURRENT_REQUEST_LIMIT_HIT = (\n        \"{UURL} hit {CONCURRENT_REQUESTS} ongoing concurrent requests\"\n    )\n    CONCURRENT_REQUEST_LIMIT_FREED = \"{UURL} concurrency has been freed\"\n\n\ndef do_log(\n    logevent: LogEvent,\n    defaultmsg: str,\n    format_args: dict[str, t.Any],\n    response: httpx.Response | None = None,\n):\n    # Let's protect ourselves against potential customizations with undefined {key}\n    safe_format_args = SafeDict(**format_args)\n\n    if logevent.custom_message:\n        if isinstance(logevent.custom_message, str):\n            message = logevent.custom_message.format_map(safe_format_args)\n        else:\n            message = logevent.custom_message(response).format_map(safe_format_args)\n    else:\n        message = defaultmsg.format_map(safe_format_args)\n\n    logger.log(logevent.level, message, extra=format_args)\n\n\ndef extract_base_format_args(request_context: GracyRequestContext) -> dict[str, str]:\n    return dict(\n        URL=request_context.url,\n        ENDPOINT=request_context.endpoint,\n        UURL=request_context.unformatted_url,\n        UENDPOINT=request_context.unformatted_endpoint,\n        METHOD=request_context.method,\n    )\n\n\ndef extract_response_format_args(response: httpx.Response | None) -> dict[str, str]:\n    status_code = response.status_code if response else \"ABORTED\"\n    elapsed = response.elapsed if response else \"UNKNOWN\"\n\n    if response and is_replay(response):\n        replayed = \"TRUE\"\n        replayed_str = \"REPLAYED\"\n    else:\n        replayed = \"FALSE\"\n        replayed_str = \"\"\n\n    return dict(\n        STATUS=str(status_code),\n        ELAPSED=str(elapsed),\n        IS_REPLAY=replayed,\n        REPLAY=replayed_str,\n    )\n\n\ndef process_log_before_request(\n    logevent: LogEvent, request_context: GracyRequestContext\n) -> None:\n    format_args = extract_base_format_args(request_context)\n    do_log(logevent, DefaultLogMessage.BEFORE, format_args)\n\n\ndef process_log_throttle(\n    logevent: LogEvent,\n    default_message: str,\n    await_time: float,\n    rule: ThrottleRule,\n    request_context: GracyRequestContext,\n):\n    format_args = dict(\n        **extract_base_format_args(request_context),\n        THROTTLE_TIME=await_time,\n        THROTTLE_LIMIT=rule.max_requests,\n        THROTTLE_TIME_RANGE=rule.readable_time_range,\n    )\n\n    do_log(logevent, default_message, format_args)\n\n\ndef process_log_retry(\n    logevent: LogEvent,\n    defaultmsg: str,\n    request_context: GracyRequestContext,\n    state: GracefulRetryState,\n    response: httpx.Response | None = None,\n):\n    maybe_response_args: dict[str, str] = {}\n    if response:\n        maybe_response_args = extract_response_format_args(response)\n\n    format_args = dict(\n        **extract_base_format_args(request_context),\n        **maybe_response_args,\n        RETRY_DELAY=state.delay,\n        RETRY_CAUSE=state.cause,\n        CUR_ATTEMPT=state.cur_attempt,\n        MAX_ATTEMPT=state.max_attempts,\n    )\n\n    do_log(logevent, defaultmsg, format_args, response)\n\n\ndef process_log_after_request(\n    logevent: LogEvent,\n    defaultmsg: str,\n    request_context: GracyRequestContext,\n    response: httpx.Response | None,\n) -> None:\n    format_args: dict[str, str] = dict(\n        **extract_base_format_args(request_context),\n        **extract_response_format_args(response),\n    )\n\n    do_log(logevent, defaultmsg, format_args, response)\n\n\ndef process_log_concurrency_limit(\n    logevent: LogEvent, count: int, request_context: GracyRequestContext\n):\n    format_args: t.Dict[str, str] = dict(\n        CONCURRENT_REQUESTS=f\"{count:,}\",\n        **extract_base_format_args(request_context),\n    )\n\n    do_log(logevent, DefaultLogMessage.CONCURRENT_REQUEST_LIMIT_HIT, format_args)\n\n\ndef process_log_concurrency_freed(\n    logevent: LogEvent, request_context: GracyRequestContext\n):\n    format_args: t.Dict[str, str] = dict(\n        **extract_base_format_args(request_context),\n    )\n    do_log(logevent, DefaultLogMessage.CONCURRENT_REQUEST_LIMIT_FREED, format_args)\n"
  },
  {
    "path": "src/gracy/_models.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport copy\nimport httpx\nimport inspect\nimport itertools\nimport logging\nimport re\nimport typing as t\nfrom abc import ABC, abstractmethod\nfrom contextlib import contextmanager\nfrom dataclasses import dataclass\nfrom datetime import datetime, timedelta\nfrom enum import Enum, IntEnum\nfrom http import HTTPStatus\nfrom threading import Lock\n\nfrom ._types import PARSER_TYPE, UNSET_VALUE, Unset\n\n\nclass LogLevel(IntEnum):\n    CRITICAL = logging.CRITICAL\n    ERROR = logging.ERROR\n    WARNING = logging.WARNING\n    INFO = logging.INFO\n    DEBUG = logging.DEBUG\n    NOTSET = logging.NOTSET\n\n\n@dataclass\nclass LogEvent:\n    level: LogLevel\n    custom_message: t.Callable[[httpx.Response | None], str] | str | None = None\n    \"\"\"You can add some placeholders to be injected in the log.\n\n    e.g.\n      - `{URL} executed`\n      - `API replied {STATUS} and took {ELAPSED}`\n      - `{METHOD} {URL} returned {STATUS}`\n      - `Becareful because {URL} is flaky`\n\n    Placeholders may change depending on the context. Check the docs to see all available placeholder.\n    \"\"\"\n\n\nLOG_EVENT_TYPE = t.Union[None, Unset, LogEvent]\n\n\nclass GracefulRetryState:\n    cur_attempt: int = 0\n    success: bool = False\n\n    last_exc: Exception | None = None\n    last_response: httpx.Response | None\n\n    def __init__(self, retry_config: GracefulRetry) -> None:\n        self._retry_config = retry_config\n        self._delay = retry_config.delay\n        self._override_delay: float | None = None\n\n    @property\n    def delay(self) -> float:\n        if self._override_delay is not None:\n            return self._override_delay\n\n        return self._delay\n\n    @property\n    def failed(self) -> bool:\n        return not self.success\n\n    @property\n    def max_attempts(self):\n        return self._retry_config.max_attempts\n\n    @property\n    def can_retry(self):\n        return self.cur_attempt <= self.max_attempts\n\n    @property\n    def cant_retry(self):\n        return not self.can_retry\n\n    @property\n    def cause(self) -> str:\n        \"\"\"Describes why the Retry was triggered\"\"\"\n\n        # Importing here to avoid cyclic imports\n        from gracy.exceptions import (\n            GracyRequestFailed,\n            GracyUserDefinedException,\n            NonOkResponse,\n            UnexpectedResponse,\n        )\n\n        if self.success:\n            return \"SUCCESSFUL\"\n\n        exc = self.last_exc\n\n        if self.last_response:\n            if isinstance(exc, NonOkResponse) or isinstance(exc, UnexpectedResponse):\n                return f\"[Bad Status Code: {self.last_response.status_code}]\"\n\n            if isinstance(exc, GracyUserDefinedException):\n                return f\"[User Error: {type(exc).__name__}]\"\n\n        if exc:\n            if isinstance(exc, GracyRequestFailed):\n                return f\"[Request Error: {type(exc.original_exc).__name__}]\"\n\n            return f\"[{type(exc).__name__}]\"\n\n        # This final block is unlikely to ever happen\n        resp = t.cast(httpx.Response, self.last_response)\n        return f\"[Bad Status Code: {resp.status_code}]\"\n\n    def increment(self, response: httpx.Response | None):\n        self.cur_attempt += 1\n\n        if self.cur_attempt > 1:\n            self._delay *= self._retry_config.delay_modifier\n\n        self._override_delay = None\n        if (\n            response\n            and self._retry_config.overrides\n            and self._retry_config.overrides.get(response.status_code)\n        ):\n            self._override_delay = self._retry_config.overrides[\n                response.status_code\n            ].delay\n\n\nSTATUS_OR_EXCEPTION = t.Union[int, t.Type[Exception]]\n\n\n@dataclass\nclass OverrideRetryOn:\n    delay: float\n\n\n@dataclass\nclass GracefulRetry:\n    delay: float\n    max_attempts: int\n\n    delay_modifier: float = 1\n    retry_on: STATUS_OR_EXCEPTION | t.Iterable[STATUS_OR_EXCEPTION] | None = None\n    log_before: LogEvent | None = None\n    log_after: LogEvent | None = None\n    log_exhausted: LogEvent | None = None\n    behavior: t.Literal[\"break\", \"pass\"] = \"break\"\n    overrides: t.Union[t.Dict[int, OverrideRetryOn], None] = None\n\n    def needs_retry(self, response_result: int) -> bool:\n        if self.retry_on is None:\n            return True\n\n        retry_on_status = self.retry_on\n        if not isinstance(retry_on_status, t.Iterable):\n            retry_on_status = {retry_on_status}\n\n        return response_result in retry_on_status\n\n    def create_state(\n        self, result: httpx.Response | None, exc: Exception | None\n    ) -> GracefulRetryState:\n        state = GracefulRetryState(self)\n        state.last_response = result\n        state.last_exc = exc\n        return state\n\n\nclass ThrottleRule:\n    url_pattern: t.Pattern[str]\n    \"\"\"\n    Which URLs do you want to account for this?\n    e.g.\n        Strict values:\n        - `\"https://myapi.com/endpoint\"`\n\n        Regex values:\n        - `\"https://.*\"`\n        - `\"http(s)?://myapi.com/.*\"`\n    \"\"\"\n\n    max_requests: int\n    \"\"\"\n    How many requests should be run `per_time_range`\n    \"\"\"\n\n    per_time_range: timedelta\n    \"\"\"\n    Used in combination with `max_requests` to measure throttle\n    \"\"\"\n\n    def __init__(\n        self,\n        url_pattern: str,\n        max_requests: int,\n        per_time_range: timedelta = timedelta(seconds=1),\n    ) -> None:\n        self.url_pattern = re.compile(url_pattern)\n        self.max_requests = max_requests\n        self.per_time_range = per_time_range\n\n        if isinstance(max_requests, float):\n            raise TypeError(f\"{max_requests=} should be an integer\")\n\n    @property\n    def readable_time_range(self) -> str:\n        seconds = self.per_time_range.total_seconds()\n        periods = {\n            (\"hour\", 3600),\n            (\"minute\", 60),\n            (\"second\", 1),\n        }\n\n        parts: list[str] = []\n        for period_name, period_seconds in periods:\n            if seconds >= period_seconds:\n                period_value, seconds = divmod(seconds, period_seconds)\n                if period_value == 1:\n                    parts.append(period_name)\n                else:\n                    parts.append(f\"{int(period_value)} {period_name}s\")\n\n            if seconds < 1:\n                break\n\n        if len(parts) == 1:\n            return parts[0]\n        else:\n            return \", \".join(parts[:-1]) + \" and \" + parts[-1]\n\n    def __str__(self) -> str:\n        return f\"{self.max_requests} requests per {self.readable_time_range} for URLs matching {self.url_pattern}\"\n\n    def calculate_await_time(self, controller: ThrottleController) -> float:\n        \"\"\"\n        Checks current reqs/second and awaits if limit is reached.\n        Returns whether limit was hit or not.\n        \"\"\"\n        rate_limit = self.max_requests\n        cur_rate = controller.calculate_requests_per_rule(\n            self.url_pattern, self.per_time_range\n        )\n\n        if cur_rate >= rate_limit:\n            time_diff = (rate_limit - cur_rate) or 1\n            waiting_time = self.per_time_range.total_seconds() / time_diff\n            return waiting_time\n\n        return 0.0\n\n\nclass ThrottleLocker:\n    def __init__(self) -> None:\n        self._regex_lock = t.DefaultDict[t.Pattern[str], Lock](Lock)\n        self._generic_lock = Lock()\n\n    @contextmanager\n    def lock_rule(self, rule: ThrottleRule):\n        with self._regex_lock[rule.url_pattern] as lock:\n            yield lock\n\n    @contextmanager\n    def lock_check(self):\n        with self._generic_lock as lock:\n            yield lock\n\n    def is_rule_throttled(self, rule: ThrottleRule) -> bool:\n        return self._regex_lock[rule.url_pattern].locked()\n\n\nTHROTTLE_LOCKER: t.Final = ThrottleLocker()\n\n\nclass GracefulThrottle:\n    rules: list[ThrottleRule] = []\n    log_limit_reached: LogEvent | None = None\n    log_wait_over: LogEvent | None = None\n\n    def __init__(\n        self,\n        rules: list[ThrottleRule] | ThrottleRule,\n        log_limit_reached: LogEvent | None = None,\n        log_wait_over: LogEvent | None = None,\n    ) -> None:\n        self.rules = rules if isinstance(rules, t.Iterable) else [rules]\n        self.log_limit_reached = log_limit_reached\n        self.log_wait_over = log_wait_over\n\n\nclass ThrottleController:\n    def __init__(self) -> None:\n        self._control = t.DefaultDict[str, t.List[datetime]](list)\n\n    def init_request(self, request_context: GracyRequestContext):\n        with THROTTLE_LOCKER.lock_check():\n            self._control[request_context.url].append(\n                datetime.now()\n            )  # This should always keep it sorted asc\n\n    def calculate_requests_per_rule(\n        self, url_pattern: t.Pattern[str], range: timedelta\n    ) -> float:\n        with THROTTLE_LOCKER.lock_check():\n            past_time_window = datetime.now() - range\n            request_rate = 0.0\n\n            request_times = sorted(\n                itertools.chain(\n                    *[\n                        started_ats\n                        for url, started_ats in self._control.items()\n                        if url_pattern.match(url)\n                    ],\n                ),\n                reverse=True,\n            )\n\n            req_idx = 0\n            total_reqs = len(request_times)\n            while req_idx < total_reqs:\n                # e.g. Limit 4 requests per 2 seconds, now is 09:55\n                # request_time=09:54 >= past_time_window=09:53\n                if request_times[req_idx] >= past_time_window:\n                    request_rate += 1\n                else:\n                    # Because it's sorted desc there's no need to keep iterating\n                    return request_rate\n\n                req_idx += 1\n\n            return request_rate\n\n    def calculate_requests_per_sec(self, url_pattern: t.Pattern[str]) -> float:\n        with THROTTLE_LOCKER.lock_check():\n            requests_per_second = 0.0\n            coalesced_started_ats = sorted(\n                itertools.chain(\n                    *[\n                        started_ats\n                        for url, started_ats in self._control.items()\n                        if url_pattern.match(url)\n                    ]\n                )\n            )\n\n            if coalesced_started_ats:\n                # Best effort to measure rate if we just performed 1 request\n                last = (\n                    coalesced_started_ats[-1]\n                    if len(coalesced_started_ats) > 1\n                    else datetime.now()\n                )\n                start = coalesced_started_ats[0]\n                elapsed = last - start\n\n                if elapsed.seconds > 0:\n                    requests_per_second = len(coalesced_started_ats) / elapsed.seconds\n\n            return requests_per_second\n\n    def debug_print(self):\n        # Intended only for local development\n        from rich.console import Console\n        from rich.table import Table\n\n        console = Console()\n        table = Table(title=\"Throttling Summary\")\n        table.add_column(\"URL\", overflow=\"fold\")\n        table.add_column(\"Count\", justify=\"right\")\n        table.add_column(\"Times\", justify=\"right\")\n\n        for url, times in self._control.items():\n            human_times = [time.strftime(\"%H:%M:%S.%f\") for time in times]\n            table.add_row(url, f\"{len(times):,}\", f\"[yellow]{human_times}[/yellow]\")\n\n        console.print(table)\n\n\nclass GracefulValidator(ABC):\n    \"\"\"\n    Run `check` raises exceptions in case it's not passing.\n    \"\"\"\n\n    @abstractmethod\n    def check(self, response: httpx.Response) -> None:\n        \"\"\"Returns `None` to pass or raise exception\"\"\"\n        pass\n\n\n@dataclass\nclass RequestTimeline:\n    url: str\n    start: float\n    end: float\n\n    @classmethod\n    def build(cls, start: float, resp: httpx.Response):\n        end = start + resp.elapsed.total_seconds()\n\n        return cls(\n            url=str(resp.url),\n            start=start,\n            end=end,\n        )\n\n\n@dataclass\nclass ConcurrentRequestLimit:\n    \"\"\"\n    Limits how many concurrent calls for a specific endpoint can be active.\n\n    e.g. If you limit 10 requests to the endpoing /xyz\n    \"\"\"\n\n    limit: int\n    uurl_pattern: t.Pattern[str] = re.compile(\".*\")\n    blocking_args: t.Optional[t.Iterable[str]] = None\n    \"\"\"\n    Combine endpoint args to decide whether to limit.\n    Optional, leaving it blank means that any request to the endpoint will be blocked\n    \"\"\"\n\n    limit_per_uurl: bool = True\n    \"\"\"\n    Whether Gracy should limit requests per UURL or the whole api.\n\n    If True, UURLs will be grouped, so:\n\n       Limit = 1\n       #1. GET /test/{VALUE} (0/1) - RUNNING\n       #3. GET /another/{VALUE} (0/1) - RUNNING\n       #2. GET /test/{VALUE} (1/1) - WAITING [Grouped with #1]\n\n    If False, every UURL will be matched, so:\n\n       Limit = 1\n       #1. GET /test/{VALUE} (0/1) - RUNNING\n       #3. GET /another/{VALUE} (0/1) - WAITING [Grouped with ALL]\n       #2. GET /test/{VALUE} (1/1) - WAITING [Grouped with ALL]\n\n    \"\"\"\n\n    log_limit_reached: LOG_EVENT_TYPE = None\n    \"\"\"\n    Log event for the first time the limit is reached.\n    It's only triggered again if the limit slows down.\n    \"\"\"\n\n    log_limit_freed: LOG_EVENT_TYPE = None\n\n    def __post_init__(self):\n        self._arg_semaphore_map: t.Dict[\n            t.Tuple[str, ...], asyncio.BoundedSemaphore\n        ] = {}\n\n    def _get_blocking_key(\n        self, request_context: GracyRequestContext\n    ) -> t.Tuple[str, ...]:\n        uurl_arg = request_context.unformatted_url if self.limit_per_uurl else \"global\"\n        args: t.List[str] = []\n\n        if self.blocking_args:\n            args = [\n                request_context.endpoint_args.get(arg, \"\") for arg in self.blocking_args\n            ]\n\n        return (uurl_arg, *args)\n\n    def get_semaphore(\n        self, request_context: GracyRequestContext\n    ) -> asyncio.BoundedSemaphore:\n        key = self._get_blocking_key(request_context)\n\n        if key not in self._arg_semaphore_map:\n            self._arg_semaphore_map[key] = asyncio.BoundedSemaphore(self.limit)\n\n        return self._arg_semaphore_map[key]\n\n\nCONCURRENT_REQUEST_TYPE = t.Union[\n    t.Iterable[ConcurrentRequestLimit], ConcurrentRequestLimit, None, Unset\n]\n\n\n@dataclass\nclass GracyConfig:\n    log_request: LOG_EVENT_TYPE = UNSET_VALUE\n    log_response: LOG_EVENT_TYPE = UNSET_VALUE\n    log_errors: LOG_EVENT_TYPE = UNSET_VALUE\n\n    retry: GracefulRetry | None | Unset = UNSET_VALUE\n\n    strict_status_code: t.Iterable[HTTPStatus] | HTTPStatus | None | Unset = UNSET_VALUE\n    \"\"\"Strictly enforces only one or many HTTP Status code to be considered as successful.\n\n    e.g. Setting it to 201 would raise exceptions for both 204 or 200\"\"\"\n\n    allowed_status_code: t.Iterable[\n        HTTPStatus\n    ] | HTTPStatus | None | Unset = UNSET_VALUE\n    \"\"\"Adds one or many HTTP Status code that would normally be considered an error\n\n    e.g. 404 would consider any 200-299 and 404 as successful.\n\n    NOTE: `strict_status_code` takes precedence.\n    \"\"\"\n\n    validators: t.Iterable[\n        GracefulValidator\n    ] | GracefulValidator | None | Unset = UNSET_VALUE\n    \"\"\"Adds one or many validators to be run for the response to decide whether it was successful or not.\n\n    NOTE: `strict_status_code` or `allowed_status_code` are executed before.\n    If none is set, it will first check whether the response has a successful code.\n    \"\"\"\n\n    parser: PARSER_TYPE = UNSET_VALUE\n    \"\"\"\n    Tell Gracy how to deal with the responses for you.\n\n    Examples:\n        - `\"default\": lambda response: response.json()`\n        - `HTTPStatus.OK: lambda response: response.json()[\"ok_data\"]`\n        - `HTTPStatus.NOT_FOUND: None`\n        - `HTTPStatus.INTERNAL_SERVER_ERROR: UserDefinedServerException`\n    \"\"\"\n\n    throttling: GracefulThrottle | None | Unset = UNSET_VALUE\n\n    concurrent_requests: CONCURRENT_REQUEST_TYPE = UNSET_VALUE\n\n    def should_retry(\n        self, response: httpx.Response | None, req_or_validation_exc: Exception | None\n    ) -> bool:\n        \"\"\"Only checks if given status requires retry. Does not consider attempts.\"\"\"\n\n        if self.has_retry:\n            retry = t.cast(GracefulRetry, self.retry)\n\n            retry_on: t.Iterable[STATUS_OR_EXCEPTION]\n            if (\n                not isinstance(retry.retry_on, t.Iterable)\n                and retry.retry_on is not None\n            ):\n                retry_on = [retry.retry_on]\n            elif retry.retry_on is None:\n                retry_on = []\n            else:\n                retry_on = retry.retry_on\n\n            if response is None:\n                if retry.retry_on is None:\n                    return True\n\n                for maybe_exc in retry_on:\n                    if inspect.isclass(maybe_exc):\n                        if isinstance(req_or_validation_exc, maybe_exc):\n                            return True\n\n                        # Importing here to avoid cyclic imports\n                        from .exceptions import GracyRequestFailed\n\n                        if isinstance(\n                            req_or_validation_exc, GracyRequestFailed\n                        ) and isinstance(req_or_validation_exc.original_exc, maybe_exc):\n                            return True\n\n                return False\n\n            response_status = response.status_code\n\n            if retry.retry_on is None:\n                if req_or_validation_exc or response.is_success is False:\n                    return True\n\n            if isinstance(retry.retry_on, t.Iterable):\n                if response_status in retry.retry_on:\n                    return True\n\n                for maybe_exc in retry.retry_on:\n                    if inspect.isclass(maybe_exc) and isinstance(\n                        req_or_validation_exc, maybe_exc\n                    ):\n                        return True\n\n            elif inspect.isclass(retry.retry_on):\n                return isinstance(req_or_validation_exc, retry.retry_on)\n\n            else:\n                return retry.retry_on == response_status\n\n        return False\n\n    @property\n    def has_retry(self) -> bool:\n        return self.retry is not None and self.retry != UNSET_VALUE\n\n    @classmethod\n    def merge_config(cls, base: GracyConfig, modifier: GracyConfig):\n        new_obj = copy.copy(base)\n\n        for key, value in vars(modifier).items():\n            if getattr(base, key) == UNSET_VALUE:\n                setattr(new_obj, key, value)\n            elif value != UNSET_VALUE:\n                setattr(new_obj, key, value)\n\n        return new_obj\n\n    def get_concurrent_limit(\n        self, context: GracyRequestContext\n    ) -> t.Optional[ConcurrentRequestLimit]:\n        if (\n            isinstance(self.concurrent_requests, Unset)\n            or self.concurrent_requests is None\n        ):\n            return None\n\n        if isinstance(self.concurrent_requests, ConcurrentRequestLimit):\n            if self.concurrent_requests.uurl_pattern.match(context.unformatted_url):\n                return self.concurrent_requests\n\n            return None\n\n        for rule in self.concurrent_requests:\n            if rule.uurl_pattern.match(context.unformatted_url):\n                return rule\n\n        return None\n\n\nDEFAULT_CONFIG: t.Final = GracyConfig(\n    log_request=None,\n    log_response=None,\n    log_errors=LogEvent(LogLevel.ERROR),\n    strict_status_code=None,\n    allowed_status_code=None,\n    retry=None,\n)\n\n\nclass BaseEndpoint(str, Enum):\n    def __str__(self) -> str:\n        return self.value\n\n\nEndpoint = t.TypeVar(\"Endpoint\", bound=t.Union[BaseEndpoint, str])  # , default=str)\n\n\nclass GracefulRequest:\n    request: httpx.Request\n    request_func: t.Callable[..., t.Awaitable[httpx.Response]]\n    \"\"\"Can't use coroutine because we need to retrigger it during retries, and coro can't be awaited twice\"\"\"\n    args: tuple[t.Any, ...]\n    kwargs: dict[str, t.Any]\n\n    def __init__(\n        self,\n        request: httpx.Request,\n        request_func: t.Callable[..., t.Awaitable[httpx.Response]],\n        *args: t.Any,\n        **kwargs: t.Any,\n    ) -> None:\n        self.request = request\n        self.request_func = request_func\n        self.args = args\n        self.kwargs = kwargs\n\n    def __call__(self) -> t.Awaitable[httpx.Response]:\n        return self.request_func(*self.args, **self.kwargs)\n\n\nclass GracyRequestContext:\n    def __init__(\n        self,\n        method: str,\n        base_url: str,\n        endpoint: str,\n        endpoint_args: t.Union[t.Dict[str, str], None],\n        active_config: GracyConfig,\n    ) -> None:\n        if base_url.endswith(\"/\"):\n            base_url = base_url[:-1]\n\n        final_endpoint = endpoint.format(**endpoint_args) if endpoint_args else endpoint\n\n        self.endpoint_args = endpoint_args or {}\n        self.endpoint = final_endpoint\n        self.unformatted_endpoint = endpoint\n\n        self.url = f\"{base_url}{self.endpoint}\"\n        self.unformatted_url = f\"{base_url}{self.unformatted_endpoint}\"\n\n        self.method = method\n        self._active_config = active_config\n\n    @property\n    def active_config(self) -> GracyConfig:\n        return self._active_config\n"
  },
  {
    "path": "src/gracy/_paginator.py",
    "content": "from __future__ import annotations\n\nimport typing as t\n\nRESP_T = t.TypeVar(\"RESP_T\")\nTOKEN_T = t.TypeVar(\"TOKEN_T\")\n\n\nclass GracyPaginator(t.Generic[RESP_T, TOKEN_T]):\n    def __init__(\n        self,\n        gracy_func: t.Callable[..., t.Awaitable[RESP_T]],\n        has_next: t.Callable[[t.Optional[RESP_T]], bool],\n        initial_token: TOKEN_T,\n        page_size: int = 20,\n        get_next_token: t.Optional[t.Callable[[RESP_T, TOKEN_T], TOKEN_T]] = None,\n        get_prev_token: t.Optional[t.Callable[[TOKEN_T], TOKEN_T]] = None,\n        prepare_params: t.Optional[\n            t.Callable[[TOKEN_T, int], t.Dict[str, t.Any]]\n        ] = None,\n        has_prev: t.Optional[t.Callable[[TOKEN_T], bool]] = None,\n    ):\n        self.has_next = has_next\n\n        self._endpoint_func = gracy_func\n        self._custom_has_prev = has_prev\n        self._prepare_endpoint_params = prepare_params\n        self._get_next_token = get_next_token\n        self._get_prev_token = get_prev_token\n\n        self._token = initial_token\n        self._page_size = page_size\n        self._cur_resp: t.Optional[RESP_T] = None\n\n    def _prepare_params(\n        self,\n        token: TOKEN_T,\n        page_size: int,\n    ) -> t.Dict[str, t.Any]:\n        if self._prepare_endpoint_params:\n            return self._prepare_endpoint_params(token, page_size)\n\n        params = dict(token=token, limit=self._page_size)\n        return params\n\n    async def _fetch_page(self) -> RESP_T:\n        params = self._prepare_params(self._token, page_size=20)\n        self._cur_resp = await self._endpoint_func(**params)\n        return self._cur_resp\n\n    def has_prev(self, token: TOKEN_T) -> bool:\n        if self._custom_has_prev:\n            return self._custom_has_prev(token)\n\n        return False\n\n    def _calculate_next_token(self, resp: RESP_T, token: TOKEN_T) -> TOKEN_T:\n        if self._get_next_token:\n            return self._get_next_token(resp, token)\n\n        raise NotImplementedError(\"GracyPaginator requires you to setup get_next_token\")  # noqa: TRY003\n\n    def _calculate_prev_token(self, token: TOKEN_T) -> TOKEN_T:\n        if self._get_prev_token:\n            return self._get_prev_token(token)\n\n        raise NotImplementedError(\"GracyPaginator requires you to setup get_prev_token\")  # noqa: TRY003\n\n    def set_page(self, token: TOKEN_T) -> None:\n        self._token = token\n\n    async def next_page(self) -> RESP_T | None:\n        if not self.has_next(self._cur_resp):\n            return None\n\n        page_result = await self._fetch_page()\n\n        self._token = self._calculate_next_token(page_result, self._token)\n\n        return page_result\n\n    async def prev_page(self):\n        if not self.has_prev(self._token):\n            return None\n\n        self._token = self._calculate_prev_token(self._token)\n\n        page_result = await self._fetch_page()\n\n        return page_result\n\n    def __aiter__(self):\n        return self\n\n    async def __anext__(self):\n        page = await self.next_page()\n        if page is None:\n            raise StopAsyncIteration\n        return page\n\n\nclass GracyOffsetPaginator(t.Generic[RESP_T], GracyPaginator[RESP_T, int]):\n    def __init__(\n        self,\n        gracy_func: t.Callable[..., t.Awaitable[RESP_T]],\n        has_next: t.Callable[[RESP_T | None], bool],\n        page_size: int = 20,\n        prepare_params: t.Callable[[int, int], t.Dict[str, t.Any]] | None = None,\n        has_prev: t.Callable[[int], bool] | None = None,\n    ):\n        super().__init__(\n            gracy_func,\n            has_next,\n            initial_token=0,\n            page_size=page_size,\n            prepare_params=prepare_params,\n            has_prev=has_prev,\n        )\n\n    def _prepare_params(\n        self,\n        token: int,\n        page_size: int,\n    ) -> t.Dict[str, t.Any]:\n        if self._prepare_endpoint_params:\n            return self._prepare_endpoint_params(token, page_size)\n\n        params = dict(offset=token, limit=self._page_size)\n        return params\n\n    def has_prev(self, token: int) -> bool:\n        if self._custom_has_prev:\n            return self._custom_has_prev(token)\n\n        return token > 0\n\n    def _calculate_next_token(self, resp: RESP_T, token: int) -> int:\n        return token + self._page_size\n\n    def _calculate_prev_token(self, token: int) -> int:\n        return token - self._page_size\n"
  },
  {
    "path": "src/gracy/_reports/_builders.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport re\nimport typing as t\nfrom collections import defaultdict\nfrom statistics import mean\n\nfrom .._models import GracyRequestContext, RequestTimeline, ThrottleController\nfrom ..replays.storages._base import GracyReplay, is_replay\nfrom ._models import (\n    GracyAggregatedRequest,\n    GracyReport,\n    GracyRequestCounters,\n    GracyRequestResult,\n)\n\nANY_REGEX: t.Final = r\".+\"\n\nREQUEST_ERROR_STATUS: t.Final = 0\nREQUEST_SUM_KEY = t.Union[int, t.Literal[\"total\", \"retries\", \"throttles\", \"replays\", 0]]\nREQUEST_SUM_PER_STATUS_TYPE = t.Dict[str, t.DefaultDict[REQUEST_SUM_KEY, int]]\n\n\nclass ReportBuilder:\n    def __init__(self) -> None:\n        self._results: t.List[GracyRequestResult] = []\n        self._counters = t.DefaultDict[str, GracyRequestCounters](GracyRequestCounters)\n        self._request_history = t.DefaultDict[str, t.List[RequestTimeline]](list)\n\n    def track(\n        self,\n        request_context: GracyRequestContext,\n        response_or_exc: t.Union[httpx.Response, Exception],\n        request_start: float,\n    ):\n        self._results.append(\n            GracyRequestResult(request_context.unformatted_url, response_or_exc)\n        )\n        if isinstance(response_or_exc, httpx.Response):\n            if is_replay(response_or_exc):\n                self._replayed(request_context)\n\n            request_entry = RequestTimeline.build(request_start, response_or_exc)\n            self._request_history[request_context.unformatted_url].append(request_entry)\n\n    def retried(self, request_context: GracyRequestContext):\n        self._counters[request_context.unformatted_url].retries += 1\n\n    def throttled(self, request_context: GracyRequestContext):\n        self._counters[request_context.unformatted_url].throttles += 1\n\n    def _replayed(self, request_context: GracyRequestContext):\n        self._counters[request_context.unformatted_url].replays += 1\n\n    def _calculate_req_rate_for_url(\n        self, unformatted_url: str, throttle_controller: ThrottleController\n    ) -> float:\n        pattern = re.compile(re.sub(r\"{(\\w+)}\", ANY_REGEX, unformatted_url))\n        rate = throttle_controller.calculate_requests_per_sec(pattern)\n        return rate\n\n    def build(\n        self,\n        throttle_controller: ThrottleController,\n        replay_settings: GracyReplay | None,\n    ) -> GracyReport:\n        requests_by_uurl = t.DefaultDict[\n            str, t.Set[t.Union[httpx.Response, Exception]]\n        ](set)\n        requests_sum: REQUEST_SUM_PER_STATUS_TYPE = defaultdict(\n            lambda: defaultdict(int)\n        )\n\n        for result in self._results:\n            requests_by_uurl[result.uurl].add(result.response)\n            requests_sum[result.uurl][\"total\"] += 1\n            if isinstance(result.response, httpx.Response):\n                requests_sum[result.uurl][result.response.status_code] += 1\n            else:\n                requests_sum[result.uurl][REQUEST_ERROR_STATUS] += 1\n\n        for uurl, counters in self._counters.items():\n            requests_sum[uurl][\"throttles\"] = counters.throttles\n            requests_sum[uurl][\"retries\"] = counters.retries\n            requests_sum[uurl][\"replays\"] = counters.replays\n\n        requests_sum = dict(\n            sorted(\n                requests_sum.items(), key=lambda item: item[1][\"total\"], reverse=True\n            )\n        )\n\n        report = GracyReport(replay_settings, self._request_history)\n\n        for uurl, data in requests_sum.items():\n            all_requests = {\n                req for req in requests_by_uurl[uurl] if isinstance(req, httpx.Response)\n            }\n\n            total_requests = data[\"total\"]\n            url_latency = [r.elapsed.total_seconds() for r in all_requests]\n\n            # Rate\n            # Use min to handle scenarios like:\n            # 10 reqs in a 2 millisecond window would produce a number >1,000 leading the user to think that we're\n            # producing 1,000 requests which isn't true.\n            rate = min(\n                self._calculate_req_rate_for_url(uurl, throttle_controller),\n                total_requests,\n            )\n\n            resp_2xx = 0\n            resp_3xx = 0\n            resp_4xx = 0\n            resp_5xx = 0\n            aborted = 0\n            retries = 0\n            throttles = 0\n            replays = 0\n\n            for maybe_status, count in data.items():\n                if maybe_status == \"total\":\n                    continue\n\n                if maybe_status == REQUEST_ERROR_STATUS:\n                    aborted += count\n                    continue\n\n                if maybe_status == \"throttles\":\n                    throttles += count\n                    continue\n\n                if maybe_status == \"retries\":\n                    retries += count\n                    continue\n\n                if maybe_status == \"replays\":\n                    replays += count\n                    continue\n\n                status = maybe_status\n                if 200 <= status < 300:\n                    resp_2xx += count\n                elif 300 <= status < 400:\n                    resp_3xx += count\n                elif 400 <= status < 500:\n                    resp_4xx += count\n                elif 500 <= status:\n                    resp_5xx += count\n\n            report_request = GracyAggregatedRequest(\n                uurl,\n                total_requests,\n                # Responses\n                resp_2xx=resp_2xx,\n                resp_3xx=resp_3xx,\n                resp_4xx=resp_4xx,\n                resp_5xx=resp_5xx,\n                reqs_aborted=aborted,\n                retries=retries,\n                throttles=throttles,\n                replays=replays,\n                # General\n                avg_latency=mean(url_latency) if url_latency else 0,\n                max_latency=max(url_latency) if url_latency else 0,\n                req_rate_per_sec=rate,\n            )\n\n            report.add_request(report_request)\n\n        return report\n"
  },
  {
    "path": "src/gracy/_reports/_models.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport typing as t\nfrom dataclasses import dataclass, field\nfrom statistics import mean\n\nfrom .._models import RequestTimeline\nfrom ..replays.storages._base import GracyReplay\n\n\n@dataclass(frozen=True)\nclass GracyRequestResult:\n    __slots__ = (\"uurl\", \"response\")\n\n    uurl: str\n    response: httpx.Response | Exception\n\n\n@dataclass\nclass GracyRequestCounters:\n    throttles: int = 0\n    retries: int = 0\n    replays: int = 0\n\n\n@dataclass\nclass ReportGenericAggregatedRequest:\n    uurl: str\n    \"\"\"unformatted url\"\"\"\n\n    total_requests: int\n\n    resp_2xx: int\n    resp_3xx: int\n    resp_4xx: int\n    resp_5xx: int\n    reqs_aborted: int\n\n    retries: int\n    throttles: int\n    replays: int\n\n    max_latency: float\n\n    @property\n    def success_rate(self) -> float:\n        if self.total_requests:\n            return (self.resp_2xx / self.total_requests) * 100\n\n        return 0\n\n    @property\n    def failed_rate(self) -> float:\n        if self.total_requests:\n            return 100.00 - self.success_rate\n\n        return 0\n\n\n@dataclass\nclass GracyAggregatedRequest(ReportGenericAggregatedRequest):\n    avg_latency: float = 0\n    req_rate_per_sec: float = 0\n\n\n@dataclass\nclass GracyAggregatedTotal(ReportGenericAggregatedRequest):\n    all_avg_latency: list[float] = field(default_factory=list)\n    all_req_rates: list[float] = field(default_factory=list)\n\n    @property\n    def avg_latency(self) -> float:\n        entries = self.all_avg_latency or [0]\n        return mean(entries)\n\n    @property\n    def req_rate_per_sec(self) -> float:\n        entries = self.all_req_rates or [0]\n        return mean(entries)\n\n    def increment_result(self, row: GracyAggregatedRequest) -> None:\n        self.total_requests += row.total_requests\n        self.resp_2xx += row.resp_2xx\n        self.resp_3xx += row.resp_3xx\n        self.resp_4xx += row.resp_4xx\n        self.resp_5xx += row.resp_5xx\n        self.reqs_aborted += row.reqs_aborted\n        self.throttles += row.throttles\n        self.retries += row.retries\n        self.replays += row.replays\n\n        self.all_avg_latency.append(row.avg_latency)\n        if row.req_rate_per_sec > 0:\n            self.all_req_rates.append(row.req_rate_per_sec)\n\n\nclass GracyReport:\n    def __init__(\n        self,\n        replay_settings: t.Optional[GracyReplay],\n        requests_timeline: t.Dict[str, t.List[RequestTimeline]],\n    ) -> None:\n        self.requests: list[GracyAggregatedRequest | GracyAggregatedTotal] = []\n        self.total = GracyAggregatedTotal(\n            \"TOTAL\",  # serves as title\n            total_requests=0,\n            resp_2xx=0,\n            resp_3xx=0,\n            resp_4xx=0,\n            resp_5xx=0,\n            reqs_aborted=0,\n            retries=0,\n            throttles=0,\n            replays=0,\n            max_latency=0,\n        )\n        self.replay_settings = replay_settings\n        self.requests_timeline = requests_timeline\n\n    def add_request(self, request: GracyAggregatedRequest) -> None:\n        self.requests.append(request)\n        self.total.increment_result(request)\n"
  },
  {
    "path": "src/gracy/_reports/_printers.py",
    "content": "from __future__ import annotations\n\nimport logging\nimport typing as t\nfrom abc import ABC, abstractmethod\nfrom datetime import datetime\n\nfrom ..replays.storages._base import GracyReplay\nfrom ._models import GracyAggregatedTotal, GracyReport\n\nlogger = logging.getLogger(\"gracy\")\n\nPRINTERS = t.Literal[\"rich\", \"list\", \"logger\", \"plotly\"]\n\n\nclass Titles:\n    url: t.Final = \"URL\"\n    total_requests: t.Final = \"Total Reqs (#)\"\n    success_rate: t.Final = \"Success (%)\"\n    failed_rate: t.Final = \"Fail (%)\"\n    avg_latency: t.Final = \"Avg Latency (s)\"\n    max_latency: t.Final = \"Max Latency (s)\"\n    resp_2xx: t.Final = \"2xx Resps\"\n    resp_3xx: t.Final = \"3xx Resps\"\n    resp_4xx: t.Final = \"4xx Resps\"\n    resp_5xx: t.Final = \"5xx Resps\"\n    reqs_aborted: t.Final = \"Aborts\"\n    retries: t.Final = \"Retries\"\n    throttles: t.Final = \"Throttles\"\n    replays: t.Final = \"Replays\"\n    req_rate_per_sec: t.Final = \"Avg Reqs/sec\"\n\n\ndef _getreplays_warn(replay_settings: GracyReplay | None) -> str:\n    res = \"\"\n    if replay_settings and replay_settings.display_report:\n        if replay_settings.records_made:\n            res = f\"{replay_settings.records_made:,} Requests Recorded\"\n\n        if replay_settings.replays_made:\n            if res:\n                res += \" / \"\n\n            res += f\"{replay_settings.replays_made:,} Requests Replayed\"\n\n    if res:\n        return f\"({res})\"\n\n    return res\n\n\ndef _format_value(\n    val: float,\n    color: str | None = None,\n    isset_color: str | None = None,\n    precision: int = 2,\n    bold: bool = False,\n    prefix: str = \"\",\n    suffix: str = \"\",\n    padprefix: int = 0,\n) -> str:\n    cur = f\"{prefix.rjust(padprefix)}{val:,.{precision}f}{suffix}\"\n\n    if bold:\n        cur = f\"[bold]{cur}[/bold]\"\n\n    if val and isset_color:\n        cur = f\"[{isset_color}]{cur}[/{isset_color}]\"\n    elif color:\n        cur = f\"[{color}]{cur}[/{color}]\"\n\n    return cur\n\n\ndef _format_int(\n    val: int,\n    color: str | None = None,\n    isset_color: str | None = None,\n    bold: bool = False,\n    prefix: str = \"\",\n    suffix: str = \"\",\n    padprefix: int = 0,\n) -> str:\n    cur = f\"{prefix.rjust(padprefix)}{val:,}{suffix}\"\n\n    if bold:\n        cur = f\"[bold]{cur}[/bold]\"\n\n    if val and isset_color:\n        cur = f\"[{isset_color}]{cur}[/{isset_color}]\"\n    elif color:\n        cur = f\"[{color}]{cur}[/{color}]\"\n\n    return cur\n\n\ndef _print_header(report: GracyReport):\n    print(\"   ____\")\n    print(\"  / ___|_ __ __ _  ___ _   _\")\n    print(\" | |  _| '__/ _` |/ __| | | |\")\n    print(\" | |_| | | | (_| | (__| |_| |\")\n    print(\"  \\\\____|_|  \\\\__,_|\\\\___|\\\\__, |\")\n    print(\n        f\"                       |___/  Requests Summary Report {_getreplays_warn(report.replay_settings)}\"\n    )\n\n\nclass BasePrinter(ABC):\n    @abstractmethod\n    def print_report(self, report: GracyReport) -> t.Any:\n        pass\n\n\nclass RichPrinter(BasePrinter):\n    def print_report(self, report: GracyReport) -> None:\n        # Dynamic import so we don't have to require it as dependency\n        from rich.console import Console\n        from rich.table import Table\n\n        in_replay_mode = (\n            report.replay_settings and report.replay_settings.display_report\n        )\n\n        console = Console()\n        title_warn = (\n            f\"[yellow]{_getreplays_warn(report.replay_settings)}[/yellow]\"\n            if in_replay_mode\n            else \"\"\n        )\n        table = Table(title=f\"Gracy Requests Summary {title_warn}\")\n\n        table.add_column(Titles.url, overflow=\"fold\")\n        table.add_column(Titles.total_requests, justify=\"right\")\n        table.add_column(Titles.success_rate, justify=\"right\")\n        table.add_column(Titles.failed_rate, justify=\"right\")\n        table.add_column(Titles.avg_latency, justify=\"right\")\n        table.add_column(Titles.max_latency, justify=\"right\")\n        table.add_column(Titles.resp_2xx, justify=\"right\")\n        table.add_column(Titles.resp_3xx, justify=\"right\")\n        table.add_column(Titles.resp_4xx, justify=\"right\")\n        table.add_column(Titles.resp_5xx, justify=\"right\")\n        table.add_column(Titles.reqs_aborted, justify=\"right\")\n        table.add_column(Titles.retries, justify=\"right\")\n        table.add_column(Titles.throttles, justify=\"right\")\n\n        if in_replay_mode:\n            table.add_column(Titles.replays, justify=\"right\")\n\n        table.add_column(Titles.req_rate_per_sec, justify=\"right\")\n\n        rows = report.requests\n        report.total.uurl = f\"[bold]{report.total.uurl}[/bold]\"\n        rows.append(report.total)\n\n        for idx, request_row in enumerate(rows):\n            is_last_line_before_footer = idx < len(rows) - 1 and isinstance(\n                rows[idx + 1], GracyAggregatedTotal\n            )\n\n            row_values: tuple[str, ...] = (\n                _format_int(request_row.total_requests, bold=True),\n                _format_value(request_row.success_rate, \"green\", suffix=\"%\"),\n                _format_value(\n                    request_row.failed_rate, None, \"red\", bold=True, suffix=\"%\"\n                ),\n                _format_value(request_row.avg_latency),\n                _format_value(request_row.max_latency),\n                _format_int(request_row.resp_2xx),\n                _format_int(request_row.resp_3xx),\n                _format_int(request_row.resp_4xx, isset_color=\"red\"),\n                _format_int(request_row.resp_5xx, isset_color=\"red\"),\n                _format_int(request_row.reqs_aborted, isset_color=\"red\"),\n                _format_int(request_row.retries, isset_color=\"yellow\"),\n                _format_int(request_row.throttles, isset_color=\"yellow\"),\n            )\n\n            if in_replay_mode:\n                row_values = (\n                    *row_values,\n                    _format_int(request_row.replays, isset_color=\"yellow\"),\n                )\n\n            table.add_row(\n                request_row.uurl,\n                *row_values,\n                _format_value(\n                    request_row.req_rate_per_sec, precision=1, suffix=\" reqs/s\"\n                ),\n                end_section=is_last_line_before_footer,\n            )\n\n        console.print(table)\n\n\nclass PlotlyPrinter(BasePrinter):\n    def print_report(self, report: GracyReport):\n        # Dynamic import so we don't have to require it as dependency\n        import pandas as pd  # pyright: ignore[reportMissingImports]\n        import plotly.express as px\n\n        df = pd.DataFrame(\n            [\n                dict(\n                    Uurl=uurl,\n                    Url=entry.url,\n                    Start=datetime.utcfromtimestamp(entry.start),\n                    Finish=datetime.utcfromtimestamp(entry.end),\n                )\n                for uurl, requests in report.requests_timeline.items()\n                for entry in requests\n            ]\n        )\n\n        fig = px.timeline(\n            df,\n            x_start=\"Start\",\n            x_end=\"Finish\",\n            y=\"Uurl\",\n            color=\"Url\",\n        )\n\n        fig.update_yaxes(autorange=\"reversed\")\n        fig.update_xaxes(tickformat=\"%H:%M:%S.%f\")\n        fig.update_layout(barmode=\"group\")\n\n        return fig\n\n\nclass ListPrinter(BasePrinter):\n    def print_report(self, report: GracyReport) -> None:\n        _print_header(report)\n\n        entries = report.requests\n        entries.append(report.total)\n        in_replay_mode = (\n            report.replay_settings and report.replay_settings.display_report\n        )\n\n        PAD_PREFIX: t.Final = 20\n\n        for idx, entry in enumerate(entries, 1):\n            title = entry.uurl if idx == len(entries) else f\"{idx}. {entry.uurl}\"\n            print(f\"\\n\\n{title}\")\n\n            print(\n                _format_int(\n                    entry.total_requests,\n                    padprefix=PAD_PREFIX,\n                    prefix=f\"{Titles.total_requests}: \",\n                )\n            )\n            print(\n                _format_value(\n                    entry.success_rate,\n                    padprefix=PAD_PREFIX,\n                    prefix=f\"{Titles.success_rate}: \",\n                    suffix=\"%\",\n                )\n            )\n            print(\n                _format_value(\n                    entry.failed_rate,\n                    padprefix=PAD_PREFIX,\n                    prefix=f\"{Titles.failed_rate}: \",\n                    suffix=\"%\",\n                )\n            )\n            print(\n                _format_value(\n                    entry.avg_latency,\n                    padprefix=PAD_PREFIX,\n                    prefix=f\"{Titles.avg_latency}: \",\n                )\n            )\n            print(\n                _format_value(\n                    entry.max_latency,\n                    padprefix=PAD_PREFIX,\n                    prefix=f\"{Titles.max_latency}: \",\n                )\n            )\n            print(\n                _format_int(\n                    entry.resp_2xx, padprefix=PAD_PREFIX, prefix=f\"{Titles.resp_2xx}: \"\n                )\n            )\n            print(\n                _format_int(\n                    entry.resp_3xx, padprefix=PAD_PREFIX, prefix=f\"{Titles.resp_3xx}: \"\n                )\n            )\n            print(\n                _format_int(\n                    entry.resp_4xx, padprefix=PAD_PREFIX, prefix=f\"{Titles.resp_4xx}: \"\n                )\n            )\n            print(\n                _format_int(\n                    entry.resp_5xx, padprefix=PAD_PREFIX, prefix=f\"{Titles.resp_5xx}: \"\n                )\n            )\n            print(\n                _format_int(\n                    entry.reqs_aborted,\n                    padprefix=PAD_PREFIX,\n                    prefix=f\"{Titles.reqs_aborted}: \",\n                )\n            )\n            print(\n                _format_int(\n                    entry.retries, padprefix=PAD_PREFIX, prefix=f\"{Titles.retries}: \"\n                )\n            )\n            print(\n                _format_int(\n                    entry.throttles,\n                    padprefix=PAD_PREFIX,\n                    prefix=f\"{Titles.throttles}: \",\n                )\n            )\n\n            if in_replay_mode:\n                print(\n                    _format_int(\n                        entry.replays,\n                        padprefix=PAD_PREFIX,\n                        prefix=f\"{Titles.replays}: \",\n                    )\n                )\n\n            print(\n                _format_value(\n                    entry.req_rate_per_sec,\n                    precision=1,\n                    padprefix=PAD_PREFIX,\n                    prefix=f\"{Titles.req_rate_per_sec}: \",\n                    suffix=\" reqs/s\",\n                )\n            )\n\n\nclass LoggerPrinter(BasePrinter):\n    def print_report(self, report: GracyReport) -> None:\n        # the first entry should be the most frequent URL hit\n        if not report.requests:\n            logger.warning(\"No requests were triggered\")\n            return\n\n        first_entry, *_ = report.requests\n        total = report.total\n\n        logger.info(\n            f\"Gracy tracked that '{first_entry.uurl}' was hit {_format_int(first_entry.total_requests)} time(s) \"\n            f\"with a success rate of {_format_value(first_entry.success_rate, suffix='%')}, \"\n            f\"avg latency of {_format_value(first_entry.avg_latency)}s, \"\n            f\"and a rate of {_format_value(first_entry.req_rate_per_sec, precision=1, suffix=' reqs/s')}.\"\n        )\n\n        logger.info(\n            f\"Gracy tracked a total of {_format_int(total.total_requests)} requests \"\n            f\"with a success rate of {_format_value(total.success_rate, suffix='%')}, \"\n            f\"avg latency of {_format_value(total.avg_latency)}s, \"\n            f\"and a rate of {_format_value(total.req_rate_per_sec, precision=1, suffix=' reqs/s')}.\"\n        )\n\n        if replay := report.replay_settings:\n            if replay.mode == \"record\":\n                logger.info(\"All requests were recorded with GracyReplay\")\n            else:\n                logger.warning(\n                    \"All requests were REPLAYED (no HTTP interaction) with GracyReplay\"\n                )\n\n\ndef print_report(report: GracyReport, method: PRINTERS) -> t.Any:\n    printer: t.Optional[BasePrinter] = None\n    if method == \"rich\":\n        printer = RichPrinter()\n    elif method == \"list\":\n        printer = ListPrinter()\n    elif method == \"logger\":\n        printer = LoggerPrinter()\n    elif method == \"plotly\":\n        printer = PlotlyPrinter()\n\n    if printer:\n        return printer.print_report(report)\n"
  },
  {
    "path": "src/gracy/_types.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport sys\nimport typing as t\nfrom http import HTTPStatus\n\nfrom typing_extensions import deprecated\n\nif sys.version_info >= (3, 10):\n    from typing import ParamSpec\nelse:\n    from typing_extensions import ParamSpec\n\n\nclass Unset:\n    \"\"\"\n    The default \"unset\" state indicates that whatever default is set on the\n    client should be used. This is different to setting `None`, which\n    explicitly disables the parameter, possibly overriding a client default.\n    \"\"\"\n\n    def __bool__(self):\n        return False\n\n\nPARSER_KEY = t.Union[HTTPStatus, int, t.Literal[\"default\"]]\nPARSER_VALUE = t.Union[t.Type[Exception], t.Callable[[httpx.Response], t.Any], None]\nPARSER_TYPE = t.Union[t.Dict[PARSER_KEY, PARSER_VALUE], Unset, None]\n\nUNSET_VALUE: t.Final = Unset()\n\n\nP = ParamSpec(\"P\")\nT = t.TypeVar(\"T\")\n\n\n@deprecated(\"Use typed http methods instead e.g. `self.get[DesiredType]()`\")\ndef parsed_response(return_type: t.Type[T]):  # type: ignore\n    def _decorated(\n        func: t.Callable[P, t.Any]\n    ) -> t.Callable[P, t.Coroutine[t.Any, t.Any, T]]:\n        async def _gracy_method(*args: P.args, **kwargs: P.kwargs) -> T:\n            return await func(*args, **kwargs)\n\n        return _gracy_method\n\n    return _decorated\n\n\n@deprecated(\"Use typed http methods instead e.g. `self.get[DesiredType]()`\")\ndef generated_parsed_response(return_type: t.Type[T]):  # type: ignore\n    def _decorated(\n        func: t.Callable[P, t.AsyncGenerator[t.Any, t.Any]]\n    ) -> t.Callable[P, t.AsyncGenerator[T, t.Any]]:\n        async def _gracy_method(\n            *args: P.args, **kwargs: P.kwargs\n        ) -> t.AsyncGenerator[T, t.Any]:\n            async for i in func(*args, **kwargs):\n                yield i\n\n        return _gracy_method\n\n    return _decorated\n"
  },
  {
    "path": "src/gracy/_validators.py",
    "content": "from __future__ import annotations\n\nimport typing as t\n\nimport httpx\n\nfrom ._models import GracefulValidator\nfrom .exceptions import NonOkResponse, UnexpectedResponse\n\n\nclass DefaultValidator(GracefulValidator):\n    def check(self, response: httpx.Response) -> None:\n        if response.is_success:\n            return None\n\n        raise NonOkResponse(str(response.url), response)\n\n\nclass StrictStatusValidator(GracefulValidator):\n    def __init__(self, status_code: t.Union[int, t.Iterable[int]]) -> None:\n        if isinstance(status_code, t.Iterable):\n            self._status_codes = status_code\n        else:\n            self._status_codes = {status_code}\n\n    def check(self, response: httpx.Response) -> None:\n        if response.status_code in self._status_codes:\n            return None\n\n        raise UnexpectedResponse(str(response.url), response, self._status_codes)\n\n\nclass AllowedStatusValidator(GracefulValidator):\n    def __init__(self, status_code: t.Union[int, t.Iterable[int]]) -> None:\n        if isinstance(status_code, t.Iterable):\n            self._status_codes = status_code\n        else:\n            self._status_codes = {status_code}\n\n    def check(self, response: httpx.Response) -> None:\n        if response.is_success:\n            return None\n\n        if response.status_code in self._status_codes:\n            return None\n\n        raise NonOkResponse(str(response.url), response)\n"
  },
  {
    "path": "src/gracy/common_hooks.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport logging\nimport typing as t\nfrom asyncio import Lock\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom http import HTTPStatus\n\nimport httpx\n\nfrom ._loggers import do_log, extract_base_format_args, extract_response_format_args\nfrom ._models import GracyRequestContext, LogEvent\nfrom ._reports._builders import ReportBuilder\nfrom .replays.storages._base import is_replay\n\nlogger = logging.getLogger(\"gracy\")\n\n\n@dataclass\nclass HookResult:\n    executed: bool\n    awaited: float = 0\n    dry_run: bool = False\n\n\nclass HttpHeaderRetryAfterBackOffHook:\n    \"\"\"\n    Provides two methods `before()` and `after()` to be used as hooks by Gracy.\n\n    This hook checks for 429 (TOO MANY REQUESTS), and then reads the\n    `retry-after` header (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After).\n\n    If the value is set, then Gracy pauses **ALL** client requests until the time is over.\n    This behavior can be modified to happen on a per-endpoint basis if `lock_per_endpoint` is True.\n\n    ### ⚠️ Retry\n    This doesn't replace `GracefulRetry`.\n    Make sure you implement a proper retry logic, otherwise the 429 will break the client.\n\n    ### Throttling\n    If you pass the reporter, it will count every await as \"throttled\" for that UURL.\n\n    ### Processor\n    You can optionally pass in a lambda, that can be used to modify/increase the wait time from the header.\n\n    ### Log Event\n    You can optionally define a log event.\n    It provides the response and the context, but also `RETRY_AFTER` that contains the header value.\n    `RETRY_AFTER_ACTUAL_WAIT` is also available in case you modify the original value.\n    \"\"\"\n\n    DEFAULT_LOG_MESSAGE: t.Final = (\n        \"[{METHOD}] {URL} requested to wait for {RETRY_AFTER}s\"\n    )\n    ALL_CLIENT_LOCK: t.Final = \"CLIENT\"\n\n    def __init__(\n        self,\n        reporter: ReportBuilder | None = None,\n        lock_per_endpoint: bool = False,\n        log_event: LogEvent | None = None,\n        seconds_processor: None | t.Callable[[float], float] = None,\n        *,\n        dry_run: bool = False,\n    ) -> None:\n        self._reporter = reporter\n        self._lock_per_endpoint = lock_per_endpoint\n        self._lock_manager = t.DefaultDict[str, Lock](Lock)\n        self._log_event = log_event\n        self._processor = seconds_processor or (lambda x: x)\n        self._dry_run = dry_run\n\n    def _process_log(\n        self,\n        request_context: GracyRequestContext,\n        response: httpx.Response,\n        retry_after: float,\n        actual_wait: float,\n    ) -> None:\n        if event := self._log_event:\n            format_args: t.Dict[str, str] = dict(\n                **extract_base_format_args(request_context),\n                **extract_response_format_args(response),\n                RETRY_AFTER=str(retry_after),\n                RETRY_AFTER_ACTUAL_WAIT=str(actual_wait),\n            )\n\n            do_log(event, self.DEFAULT_LOG_MESSAGE, format_args, response)\n\n    def _parse_retry_after_as_seconds(self, response: httpx.Response) -> float:\n        retry_after_value = response.headers.get(\"retry-after\")\n\n        if retry_after_value is None:\n            return 0\n\n        if retry_after_value.isdigit():\n            return int(retry_after_value)\n\n        try:\n            # It might be a date as: Wed, 21 Oct 2015 07:28:00 GMT\n            date_time = datetime.strptime(retry_after_value, \"%a, %d %b %Y %H:%M:%S %Z\")\n            date_as_seconds = (date_time - datetime.now()).total_seconds()\n\n        except Exception:\n            logger.exception(\n                f\"Unable to parse {retry_after_value} as date within {type(self).__name__}\"\n            )\n            return 0\n\n        else:\n            return date_as_seconds\n\n    async def before(self, context: GracyRequestContext) -> HookResult:\n        return HookResult(False)\n\n    async def after(\n        self,\n        context: GracyRequestContext,\n        response_or_exc: httpx.Response | Exception,\n    ) -> HookResult:\n        if (\n            isinstance(response_or_exc, httpx.Response)\n            and response_or_exc.status_code == HTTPStatus.TOO_MANY_REQUESTS\n        ):\n            if is_replay(response_or_exc):\n                return HookResult(executed=False, dry_run=self._dry_run)\n\n            retry_after_seconds = self._parse_retry_after_as_seconds(response_or_exc)\n            actual_wait = self._processor(retry_after_seconds)\n\n            if retry_after_seconds > 0:\n                lock_name = (\n                    context.unformatted_url\n                    if self._lock_per_endpoint\n                    else self.ALL_CLIENT_LOCK\n                )\n\n                async with self._lock_manager[lock_name]:\n                    self._process_log(\n                        context, response_or_exc, retry_after_seconds, actual_wait\n                    )\n\n                    if self._reporter:\n                        self._reporter.throttled(context)\n\n                    if self._dry_run is False:\n                        await asyncio.sleep(actual_wait)\n\n                    return HookResult(True, actual_wait, self._dry_run)\n\n        return HookResult(False, dry_run=self._dry_run)\n\n\nclass RateLimitBackOffHook:\n    \"\"\"\n    Provides two methods `before()` and `after()` to be used as hooks by Gracy.\n\n    This hook checks for 429 (TOO MANY REQUESTS) and locks requests for an arbitrary amount of time defined by you.\n\n    If the value is set, then Gracy pauses **ALL** client requests until the time is over.\n    This behavior can be modified to happen on a per-endpoint basis if `lock_per_endpoint` is True.\n\n    ### ⚠️ Retry\n    This doesn't replace `GracefulRetry`.\n    Make sure you implement a proper retry logic, otherwise the 429 will break the client.\n\n    ### Throttling\n    If you pass the reporter, it will count every await as \"throttled\" for that UURL.\n\n    ### Log Event\n    You can optionally define a log event.\n    It provides the response and the context, but also `WAIT_TIME` that contains the wait value.\n    \"\"\"\n\n    DEFAULT_LOG_MESSAGE: t.Final = (\n        \"[{METHOD}] {UENDPOINT} got rate limited, waiting for {WAIT_TIME}s\"\n    )\n    ALL_CLIENT_LOCK: t.Final = \"CLIENT\"\n\n    def __init__(\n        self,\n        delay: float,\n        reporter: ReportBuilder | None = None,\n        lock_per_endpoint: bool = False,\n        log_event: LogEvent | None = None,\n        *,\n        dry_run: bool = False,\n    ) -> None:\n        self._reporter = reporter\n        self._lock_per_endpoint = lock_per_endpoint\n        self._lock_manager = t.DefaultDict[str, Lock](Lock)\n        self._log_event = log_event\n        self._delay = delay\n        self._dry_run = dry_run\n\n    def _process_log(\n        self, request_context: GracyRequestContext, response: httpx.Response\n    ) -> None:\n        if event := self._log_event:\n            format_args: t.Dict[str, str] = dict(\n                **extract_base_format_args(request_context),\n                **extract_response_format_args(response),\n                WAIT_TIME=str(self._delay),\n            )\n\n            do_log(event, self.DEFAULT_LOG_MESSAGE, format_args, response)\n\n    async def before(self, context: GracyRequestContext) -> HookResult:\n        return HookResult(False)\n\n    async def after(\n        self,\n        context: GracyRequestContext,\n        response_or_exc: httpx.Response | Exception,\n    ) -> HookResult:\n        if (\n            isinstance(response_or_exc, httpx.Response)\n            and response_or_exc.status_code == HTTPStatus.TOO_MANY_REQUESTS\n        ):\n            if is_replay(response_or_exc):\n                return HookResult(executed=False, dry_run=self._dry_run)\n\n            lock_name = (\n                context.unformatted_url\n                if self._lock_per_endpoint\n                else self.ALL_CLIENT_LOCK\n            )\n\n            async with self._lock_manager[lock_name]:\n                self._process_log(context, response_or_exc)\n\n                if self._reporter:\n                    self._reporter.throttled(context)\n\n                if self._dry_run is False:\n                    await asyncio.sleep(self._delay)\n\n                return HookResult(True, self._delay, self._dry_run)\n\n        return HookResult(False, dry_run=self._dry_run)\n"
  },
  {
    "path": "src/gracy/exceptions.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport typing as t\nfrom abc import ABC, abstractmethod\n\nfrom ._models import GracyRequestContext\n\nREDUCE_PICKABLE_RETURN = t.Tuple[t.Type[Exception], t.Tuple[t.Any, ...]]\n\n\nclass GracyException(Exception, ABC):\n    @abstractmethod\n    def __reduce__(self) -> REDUCE_PICKABLE_RETURN:\n        \"\"\"\n        `__reduce__` is required to avoid Gracy from breaking in different\n        environments that pickles the results (e.g. inside ThreadPools).\n\n        More context: https://stackoverflow.com/a/36342588/2811539\n        \"\"\"\n        pass\n\n\nclass GracyRequestFailed(GracyException):\n    \"\"\"\n    Sometimes the httpx's request fails for whatever reason (TCP, SSL, etc errors), this\n    is a wrapper exception so the client can be easily \"retried\" for any failed requests.\n\n    NOTE: Consider that failed requests means NO RESPONSE because the request never completed\n\n    Maybe this would be an `ExceptionGroup` if Gracy ever deprecates Python < 3.11\n    \"\"\"\n\n    def __init__(self, context: GracyRequestContext, original_exc: Exception) -> None:\n        self.original_exc = original_exc\n        self.request_context = context\n\n        original_exc_name = self._get_exc_name(original_exc)\n\n        super().__init__(\n            f\"The request for [{context.method}] {context.url} never got a response due to {original_exc_name} \"\n        )\n\n        # Inspired by https://stackoverflow.com/a/54716092/2811539\n        # We include the original exception as part of the stack trace by doing that.\n        self.__cause__ = original_exc\n        self.__context__ = original_exc\n\n    @staticmethod\n    def _get_exc_name(exc: Exception) -> str:\n        \"\"\"\n        Formats the exception as \"module.ClassType\"\n\n        e.g. httpx.ReadTimeout\n        \"\"\"\n        exc_type = type(exc)\n\n        module = exc_type.__module__\n        if module is not None and module != \"__main__\":\n            return module + \".\" + exc_type.__qualname__\n\n        return exc_type.__qualname__\n\n    def __reduce__(self) -> REDUCE_PICKABLE_RETURN:\n        return (GracyRequestFailed, (self.request_context, self.original_exc))\n\n\nclass GracyParseFailed(GracyException):\n    def __init__(self, response: httpx.Response) -> None:\n        msg = (\n            f\"Unable to parse result from [{response.request.method}] {response.url} ({response.status_code}). \"\n            f\"Response content is: {response.text}\"\n        )\n\n        self.url = response.request.url\n        self.response = response\n\n        super().__init__(msg)\n\n    def __reduce__(self) -> REDUCE_PICKABLE_RETURN:\n        return (GracyParseFailed, (self.response,))\n\n\nclass BadResponse(GracyException):\n    def __init__(\n        self,\n        message: str | None,\n        url: str,\n        response: httpx.Response,\n        expected: str | int | t.Iterable[int],\n    ) -> None:\n        self.url = url\n        self.response = response\n\n        self._args = (\n            message,\n            url,\n            response,\n            expected,\n        )\n\n        if isinstance(expected, str):\n            expectedstr = expected\n        elif isinstance(expected, int):\n            expectedstr = str(expected)\n        else:\n            expectedstr = \", \".join([str(s) for s in expected])\n\n        curmsg = (\n            message\n            or f\"{url} raised {response.status_code}, but it was expecting {expectedstr}\"\n        )\n\n        super().__init__(curmsg)\n\n    def __reduce__(self) -> REDUCE_PICKABLE_RETURN:\n        return (BadResponse, self._args)\n\n\nclass UnexpectedResponse(BadResponse):\n    def __init__(\n        self, url: str, response: httpx.Response, expected: str | int | t.Iterable[int]\n    ) -> None:\n        super().__init__(None, url, response, expected)\n\n        self.url = url\n        self.response = response\n        self.expected = expected\n\n    def __reduce__(self) -> REDUCE_PICKABLE_RETURN:\n        return (UnexpectedResponse, (self.url, self.response, self.expected))\n\n\nclass NonOkResponse(BadResponse):\n    def __init__(self, url: str, response: httpx.Response) -> None:\n        super().__init__(None, url, response, \"any successful status code\")\n\n        self.arg1 = url\n        self.arg2 = response\n\n    def __reduce__(self) -> REDUCE_PICKABLE_RETURN:\n        return (NonOkResponse, (self.arg1, self.arg2))\n\n\nclass GracyUserDefinedException(GracyException):\n    BASE_MESSAGE: str = \"[{METHOD}] {URL} returned {}\"\n\n    def __init__(\n        self, request_context: GracyRequestContext, response: httpx.Response\n    ) -> None:\n        self._request_context = request_context\n        self._response = response\n        super().__init__(self._format_message(request_context, response))\n\n    def _build_default_args(self) -> dict[str, t.Any]:\n        request_context = self._request_context\n\n        return dict(\n            # Context\n            ENDPOINT=request_context.endpoint,\n            UURL=request_context.unformatted_url,\n            UENDPOINT=request_context.unformatted_endpoint,\n            # Response\n            URL=self.response.request.url,\n            METHOD=self.response.request.method,\n            STATUS=self.response.status_code,\n            ELAPSED=self.response.elapsed,\n        )\n\n    def _format_message(\n        self, request_context: GracyRequestContext, response: httpx.Response\n    ) -> str:\n        format_args = self._build_default_args()\n        return self.BASE_MESSAGE.format(**format_args)\n\n    @property\n    def url(self):\n        return self._request_context.url\n\n    @property\n    def endpoint(self):\n        return self._request_context.endpoint\n\n    @property\n    def response(self):\n        return self._response\n\n    def __reduce__(self) -> REDUCE_PICKABLE_RETURN:\n        return (GracyUserDefinedException, (self._request_context, self._response))\n\n\nclass GracyReplayRequestNotFound(GracyException):\n    def __init__(self, request: httpx.Request) -> None:\n        self.request = request\n\n        msg = f\"Gracy was unable to replay {request.method} {request.url} - did you forget to record it?\"\n        super().__init__(msg)\n\n    def __reduce__(self) -> REDUCE_PICKABLE_RETURN:\n        return (GracyReplayRequestNotFound, (self.request,))\n"
  },
  {
    "path": "src/gracy/py.typed",
    "content": ""
  },
  {
    "path": "src/gracy/replays/_wrappers.py",
    "content": "from __future__ import annotations\n\nimport typing as t\nfrom functools import wraps\n\nimport httpx\n\nfrom gracy._general import extract_request_kwargs\nfrom gracy.exceptions import GracyReplayRequestNotFound\n\nfrom .storages._base import GracyReplay\n\nhttpx_func_type = t.Callable[..., t.Awaitable[httpx.Response]]\n\n\ndef record_mode(replay: GracyReplay, httpx_request_func: httpx_func_type):\n    @wraps(httpx_request_func)\n    async def _wrapper(*args: t.Any, **kwargs: t.Any):\n        httpx_response = await httpx_request_func(*args, **kwargs)\n        await replay.storage.record(httpx_response)\n        replay.inc_record()\n\n        return httpx_response\n\n    return _wrapper\n\n\ndef replay_mode(\n    replay: GracyReplay, client: httpx.AsyncClient, httpx_request_func: httpx_func_type\n):\n    @wraps(httpx_request_func)\n    async def _wrapper(*args: t.Any, **kwargs: t.Any):\n        request_kwargs = extract_request_kwargs(kwargs)\n        request = client.build_request(*args, **request_kwargs)\n\n        stored_response = await replay.storage.load(\n            request,\n            replay.discard_replays_older_than,\n            replay.discard_bad_responses,\n        )\n        replay.inc_replay()\n\n        return stored_response\n\n    return _wrapper\n\n\ndef smart_replay_mode(\n    replay: GracyReplay, client: httpx.AsyncClient, httpx_request_func: httpx_func_type\n):\n    @wraps(httpx_request_func)\n    async def _wrapper(*args: t.Any, **kwargs: t.Any):\n        request_kwargs = extract_request_kwargs(kwargs)\n        request = client.build_request(*args, **request_kwargs)\n\n        try:\n            stored_response = await replay.storage.load(\n                request,\n                replay.discard_replays_older_than,\n                replay.discard_bad_responses,\n            )\n\n        except GracyReplayRequestNotFound:\n            httpx_response = await httpx_request_func(*args, **kwargs)\n            await replay.storage.record(httpx_response)\n            response = httpx_response\n            replay.inc_record()\n\n        else:\n            response = stored_response\n            replay.inc_replay()\n\n        return response\n\n    return _wrapper\n"
  },
  {
    "path": "src/gracy/replays/storages/_base.py",
    "content": "from __future__ import annotations\n\nimport logging\nimport typing as t\nfrom abc import ABC, abstractmethod\nfrom dataclasses import dataclass\nfrom datetime import datetime\n\nimport httpx\n\nfrom gracy.exceptions import GracyReplayRequestNotFound\n\nfrom ..._loggers import DefaultLogMessage, do_log\nfrom ..._models import LogEvent\n\nlogger = logging.getLogger(__name__)\n\n\nREPLAY_FLAG: t.Final = \"_gracy_replayed\"\n\n\ndef is_replay(resp: httpx.Response) -> bool:\n    return getattr(resp, REPLAY_FLAG, False)\n\n\nclass GracyReplayStorage(ABC):\n    def prepare(self) -> None:\n        \"\"\"(Optional) Executed upon API instance creation.\"\"\"\n        pass\n\n    @abstractmethod\n    async def record(self, response: httpx.Response) -> None:\n        \"\"\"Logic to store the response object. Note the httpx.Response has request data\"\"\"\n        pass\n\n    @abstractmethod\n    async def find_replay(\n        self, request: httpx.Request, discard_before: datetime | None\n    ) -> t.Any | None:\n        pass\n\n    @abstractmethod\n    async def _load(\n        self, request: httpx.Request, discard_before: datetime | None\n    ) -> httpx.Response:\n        \"\"\"Logic to load a response object based on the request. Raises `GracyReplayRequestNotFound` if missing\"\"\"\n        pass\n\n    async def load(\n        self,\n        request: httpx.Request,\n        discard_before: datetime | None,\n        discard_bad_responses: bool = False,\n    ) -> httpx.Response:\n        \"\"\"Logic to load a response object based on the request. Raises `GracyReplayRequestNotFound` if missing\"\"\"\n        resp = await self._load(request, discard_before)\n        setattr(resp, REPLAY_FLAG, True)\n\n        if discard_bad_responses and resp.is_success is False:\n            raise GracyReplayRequestNotFound(request)\n\n        return resp\n\n    def flush(self) -> None:\n        \"\"\"(Optional) Executed during close (preferably once all requests were made).\"\"\"\n        pass\n\n\n@dataclass\nclass ReplayLogEvent(LogEvent):\n    frequency: int = 1_000\n    \"\"\"Defines how often to log when request is recorded/replayed\"\"\"\n\n\n@dataclass\nclass GracyReplay:\n    mode: t.Literal[\"record\", \"replay\", \"smart-replay\"]\n    \"\"\"\n    `record`: Will record all requests made to the API\n    `replay`: Will read all responses from the defined storage\n    `smart-replay`: Will read all responses (like `replay`), but if it's missing it will `record` for future replays\n    \"\"\"\n\n    storage: GracyReplayStorage\n    \"\"\"Where to read/write requests and responses\"\"\"\n\n    discard_replays_older_than: datetime | None = None\n    \"\"\"If set, Gracy will treat all replays older than defined value as not found\"\"\"\n\n    discard_bad_responses: bool = False\n    \"\"\"If set True, then Gracy will discard bad requests (e.g. non 2xx)\"\"\"\n\n    disable_throttling: bool = False\n    \"\"\"Only applicable to `smart-replay` and `replay` modes. If a replay exists then don't throttle the request\"\"\"\n\n    display_report: bool = True\n    \"\"\"Whether to display records made and replays made to the final report\"\"\"\n\n    log_record: ReplayLogEvent | None = None\n    \"\"\"Whether to log and how often to upon recording requests. The only available placeholder is `RECORDED_COUNT`\"\"\"\n\n    log_replay: ReplayLogEvent | None = None\n    \"\"\"Whether to log and how often to upon replaying requests. The only available placeholder is `REPLAYED_COUNT`\"\"\"\n\n    records_made: int = 0\n    replays_made: int = 0\n\n    async def has_replay(self, request: httpx.Request) -> bool:\n        replay = await self.storage.find_replay(\n            request, self.discard_replays_older_than\n        )\n        return bool(replay)\n\n    def inc_record(self):\n        self.records_made += 1\n\n        if log_ev := self.log_record:\n            if self.records_made % log_ev.frequency == 0:\n                args = dict(RECORDED_COUNT=f\"{self.records_made:,}\")\n                do_log(log_ev, DefaultLogMessage.REPLAY_RECORDED, args)\n\n    def inc_replay(self):\n        self.replays_made += 1\n\n        if log_ev := self.log_replay:\n            if self.replays_made % log_ev.frequency == 0:\n                args = dict(REPLAYED_COUNT=f\"{self.replays_made:,}\")\n                do_log(log_ev, DefaultLogMessage.REPLAY_REPLAYED, args)\n"
  },
  {
    "path": "src/gracy/replays/storages/_sqlite_schema.py",
    "content": "from __future__ import annotations\n\nimport typing as t\n\nTABLE_NAME: t.Final = \"gracy_recordings\"\n\nCREATE_RECORDINGS_TABLE: t.Final = f\"\"\"\nCREATE TABLE {TABLE_NAME}(\n    url VARCHAR(255) NOT NULL,\n    method VARCHAR(20) NOT NULL,\n    request_body BLOB NULL,\n    response BLOB NOT NULL,\n    updated_at DATETIME NOT NULL\n)\n\"\"\"\n\nINDEX_RECORDINGS_TABLE: t.Final = f\"\"\"\nCREATE UNIQUE INDEX idx_gracy_request\nON {TABLE_NAME}(url, method, request_body)\n\"\"\"\n\nINDEX_RECORDINGS_TABLE_WITHOUT_REQUEST_BODY: t.Final = f\"\"\"\nCREATE UNIQUE INDEX idx_gracy_request_empty_req_body\nON {TABLE_NAME}(url, method)\nWHERE request_body IS NULL\n\"\"\"\n\nINSERT_RECORDING_BASE: t.Final = f\"\"\"\nINSERT OR REPLACE INTO {TABLE_NAME}\nVALUES (?, ?, ?, ?, ?)\n\"\"\"\n\nFIND_REQUEST_WITH_REQ_BODY: t.Final = f\"\"\"\nSELECT response, updated_at FROM {TABLE_NAME}\nWHERE\nurl = ? AND\nmethod = ? AND\nrequest_body = ?\n\"\"\"\n\nFIND_REQUEST_WITHOUT_REQ_BODY: t.Final = f\"\"\"\nSELECT response, updated_at FROM {TABLE_NAME}\nWHERE\nurl = ? AND\nmethod = ? AND\nrequest_body IS NULL\n\"\"\"\n"
  },
  {
    "path": "src/gracy/replays/storages/pymongo.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport json\nimport pickle\nimport typing as t\nfrom dataclasses import asdict, dataclass\nfrom datetime import datetime\nfrom threading import Lock\n\nfrom gracy.exceptions import GracyReplayRequestNotFound\n\nfrom ._base import GracyReplayStorage\n\ntry:\n    import pymongo\nexcept ModuleNotFoundError:\n    pass\n\n\n@dataclass\nclass MongoCredentials:\n    host: str | None = None\n    \"\"\"Can be a full URI\"\"\"\n    port: int = 27017\n    username: str | None = None\n    password: str | None = None\n\n\nclass MongoReplayDocument(t.TypedDict):\n    url: str\n    method: str\n    request_body: bytes | None\n    response: bytes\n    response_content: dict[str, t.Any] | str | None\n    \"\"\"Useful for debugging since Mongo supports unstructured data\"\"\"\n    updated_at: datetime\n\n\ndef get_unique_keys_from_doc(\n    replay_doc: MongoReplayDocument,\n) -> t.Dict[str, bytes | None | str]:\n    return {\n        \"url\": replay_doc[\"url\"],\n        \"method\": replay_doc[\"method\"],\n        \"request_body\": replay_doc[\"request_body\"],\n    }\n\n\ndef get_unique_keys_from_request(\n    request: httpx.Request,\n) -> t.Dict[str, bytes | None | str]:\n    return {\n        \"url\": str(request.url),\n        \"method\": request.method,\n        \"request_body\": request.content or None,\n    }\n\n\nbatch_lock = Lock()\n\n\nclass MongoReplayStorage(GracyReplayStorage):\n    def __init__(\n        self,\n        creds: MongoCredentials,\n        database_name: str = \"gracy\",\n        collection_name: str = \"gracy-replay\",\n        batch_size: int | None = None,\n    ) -> None:\n        creds_kwargs = asdict(creds)\n\n        client = pymongo.MongoClient(**creds_kwargs, document_class=MongoReplayDocument)  # pyright: ignore[reportPossiblyUnboundVariable]\n        mongo_db = client[database_name]\n        self._collection = mongo_db[collection_name]\n        self._batch = batch_size\n        self._batch_ops: t.List[pymongo.ReplaceOne[MongoReplayDocument]] = []\n\n    def _flush_batch(self) -> None:\n        if self._batch_ops:\n            with batch_lock:\n                self._collection.bulk_write(self._batch_ops)  # type: ignore\n                self._batch_ops = []\n\n    def _create_or_batch(self, doc: MongoReplayDocument) -> None:\n        filter = get_unique_keys_from_doc(doc)\n        if self._batch and self._batch > 1:\n            with batch_lock:\n                self._batch_ops.append(pymongo.ReplaceOne(filter, doc, upsert=True))  # pyright: ignore[reportPossiblyUnboundVariable]\n\n            if len(self._batch_ops) >= self._batch:\n                self._flush_batch()\n\n        else:\n            self._collection.replace_one(filter, doc, upsert=True)\n\n    def prepare(self) -> None:\n        self._collection.create_index(\n            [(\"url\", 1), (\"method\", 1), (\"request_body\", 1)],\n            background=True,\n            unique=True,\n        )\n\n    async def record(self, response: httpx.Response) -> None:\n        response_serialized = pickle.dumps(response)\n\n        response_content = response.text or None\n        content_type = response.headers.get(\"Content-Type\")\n        if content_type and \"json\" in content_type:\n            try:\n                jsonified_content = response.json()\n\n            except json.decoder.JSONDecodeError:\n                pass\n\n            else:\n                response_content = jsonified_content\n\n        doc = MongoReplayDocument(\n            url=str(response.url),\n            method=str(response.request.method),\n            request_body=response.request.content or None,\n            response=response_serialized,\n            response_content=response_content,\n            updated_at=datetime.now(),\n        )\n\n        self._create_or_batch(doc)\n\n    async def find_replay(\n        self, request: httpx.Request, discard_before: datetime | None\n    ) -> MongoReplayDocument | None:\n        filter = get_unique_keys_from_request(request)\n        doc = self._collection.find_one(filter)\n\n        if doc is None:\n            return None\n\n        if discard_before and doc[\"updated_at\"] < discard_before:\n            return None\n\n        return doc\n\n    async def _load(\n        self, request: httpx.Request, discard_before: datetime | None\n    ) -> httpx.Response:\n        doc = await self.find_replay(request, discard_before)\n\n        if doc is None:\n            raise GracyReplayRequestNotFound(request)\n\n        serialized_response = doc[\"response\"]\n        response: httpx.Response = pickle.loads(serialized_response)\n\n        return response\n\n    def flush(self) -> None:\n        self._flush_batch()\n"
  },
  {
    "path": "src/gracy/replays/storages/sqlite.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport logging\nimport pickle\nimport sqlite3\nimport typing as t\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom gracy.exceptions import GracyReplayRequestNotFound\n\nfrom . import _sqlite_schema as schema\nfrom ._base import GracyReplayStorage\n\nlogger = logging.getLogger(__name__)\n\n\n@dataclass\nclass GracyRecording:\n    url: str\n    method: str\n\n    request_body: bytes | None\n    response: bytes\n\n    updated_at: datetime\n\n\nclass SQLiteReplayStorage(GracyReplayStorage):\n    def __init__(\n        self, db_name: str = \"gracy-records.sqlite3\", dir: str = \".gracy\"\n    ) -> None:\n        self.db_dir = Path(dir)\n        self.db_file = self.db_dir / db_name\n        self._con: sqlite3.Connection = None  # type: ignore\n\n    def _create_db(self) -> None:\n        logger.info(\"Creating Gracy Replay sqlite database\")\n        con = sqlite3.connect(str(self.db_file))\n        cur = con.cursor()\n\n        cur.execute(schema.CREATE_RECORDINGS_TABLE)\n        cur.execute(schema.INDEX_RECORDINGS_TABLE)\n        cur.execute(schema.INDEX_RECORDINGS_TABLE_WITHOUT_REQUEST_BODY)\n\n    def _insert_into_db(self, recording: GracyRecording) -> None:\n        cur = self._con.cursor()\n\n        params = (\n            recording.url,\n            recording.method,\n            recording.request_body,\n            recording.response,\n            datetime.now(),\n        )\n        cur.execute(schema.INSERT_RECORDING_BASE, params)\n        self._con.commit()\n\n    def prepare(self) -> None:\n        self.db_dir.mkdir(parents=True, exist_ok=True)\n        if self.db_file.exists() is False:\n            self._create_db()\n\n        self._con = sqlite3.connect(str(self.db_file))\n\n    async def record(self, response: httpx.Response) -> None:\n        response_serialized = pickle.dumps(response)\n\n        recording = GracyRecording(\n            str(response.url),\n            str(response.request.method),\n            response.request.content or None,\n            response_serialized,\n            datetime.now(),\n        )\n\n        self._insert_into_db(recording)\n\n    def _find_record(self, request: httpx.Request):\n        cur = self._con.cursor()\n        params: t.Iterable[str | bytes]\n\n        if bool(request.content):\n            params = (str(request.url), request.method, request.content)\n            cur.execute(schema.FIND_REQUEST_WITH_REQ_BODY, params)\n        else:\n            params = (str(request.url), request.method)\n            cur.execute(schema.FIND_REQUEST_WITHOUT_REQ_BODY, params)\n\n        fetch_res = cur.fetchone()\n        return fetch_res\n\n    async def find_replay(\n        self, request: httpx.Request, discard_before: datetime | None\n    ) -> t.Any | None:\n        fetch_res = self._find_record(request)\n        if fetch_res is None:\n            return None\n\n        updated_at: datetime = fetch_res[1]\n        if discard_before and updated_at < discard_before:\n            return None\n\n        return fetch_res\n\n    async def _load(\n        self, request: httpx.Request, discard_before: datetime | None\n    ) -> httpx.Response:\n        fetch_res = await self.find_replay(request, discard_before)\n\n        if fetch_res is None:\n            raise GracyReplayRequestNotFound(request)\n\n        serialized_response: bytes = fetch_res[0]\n        response: httpx.Response = pickle.loads(serialized_response)\n\n        return response\n"
  },
  {
    "path": "src/tests/conftest.py",
    "content": "from __future__ import annotations\n\nimport typing as t\n\nimport httpx\n\nfrom gracy import BaseEndpoint, Gracy, GracyReplay\nfrom gracy.replays.storages.sqlite import SQLiteReplayStorage\n\nMISSING_NAME: t.Final = \"doesnt-exist\"\n\"\"\"Should match what we recorded previously to successfully replay\"\"\"\n\nPRESENT_POKEMON_NAME: t.Final = \"charmander\"\n\"\"\"Should match what we recorded previously to successfully replay\"\"\"\n\nPRESENT_BERRY_NAME: t.Final = \"cheri\"\n\"\"\"Should match what we recorded previously to successfully replay\"\"\"\n\nREPLAY: t.Final = GracyReplay(\"replay\", SQLiteReplayStorage(\"pokeapi.sqlite3\"))\n\n\nclass FakeReplayStorage(SQLiteReplayStorage):\n    \"\"\"Completely ignores the request defined to return a response matching the urls in the order specified\"\"\"\n\n    def __init__(self, force_urls: t.List[str]) -> None:\n        self._force_urls = force_urls\n        self._response_idx = 0\n        super().__init__(\"pokeapi.sqlite3\")\n\n    def _find_record(self, request: httpx.Request):\n        cur = self._con.cursor()\n        url = self._force_urls[self._response_idx]\n        self._response_idx += 1\n\n        cur.execute(\n            \"\"\"\n            SELECT response, updated_at FROM gracy_recordings\n            WHERE\n            url = ?\"\"\",\n            (url,),\n        )\n\n        return cur.fetchone()\n\n\nclass PokeApiEndpoint(BaseEndpoint):\n    GET_POKEMON = \"/pokemon/{NAME}\"\n    GET_BERRY = \"/berry/{NAME}\"\n\n\ndef assert_one_request_made(gracy_api: Gracy[PokeApiEndpoint]):\n    report = gracy_api.get_report()\n    assert len(report.requests) == 1\n\n\ndef assert_requests_made(\n    gracy_api: Gracy[PokeApiEndpoint], total_requests: int, endpoints_count: int = 1\n):\n    report = gracy_api.get_report()\n\n    assert len(report.requests) == endpoints_count\n    assert report.requests[0].total_requests == total_requests\n\n\ndef assert_muiti_endpoints_requests_made(\n    gracy_api: Gracy[PokeApiEndpoint],\n    endpoints_count: int,\n    *total_requests: int,\n):\n    report = gracy_api.get_report()\n\n    assert len(report.requests) == endpoints_count\n\n    for i, expected_total in enumerate(total_requests):\n        assert report.requests[i].total_requests == expected_total\n"
  },
  {
    "path": "src/tests/generate_test_db.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport typing as t\n\nfrom gracy import BaseEndpoint, Gracy, GracyReplay\nfrom gracy.replays.storages.sqlite import SQLiteReplayStorage\n\n\nclass PokeApiEndpoint(BaseEndpoint):\n    GET_POKEMON = \"/pokemon/{NAME}\"\n    GET_BERRY = \"/berry/{NAME}\"\n    GET_GENERATION = \"/generation/{ID}\"\n\n\nclass GracefulPokeAPIRecorder(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n\n    def __init__(self) -> None:\n        record_mode: t.Final = GracyReplay(\n            \"record\",\n            SQLiteReplayStorage(\"pokeapi.sqlite3\"),\n        )\n\n        super().__init__(record_mode)\n\n    async def get_pokemon(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    async def get_berry(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_BERRY, {\"NAME\": name})\n\n    async def get_generation(self, gen: int):\n        return await self.get(PokeApiEndpoint.GET_GENERATION, {\"ID\": str(gen)})\n\n\nasync def main():\n    pokeapi = GracefulPokeAPIRecorder()\n    poke_names = {\"pikachu\", \"elekid\", \"charmander\", \"blaziken\", \"hitmonchan\"}\n\n    try:\n        get_pokemons = [\n            asyncio.create_task(pokeapi.get_pokemon(name)) for name in poke_names\n        ]\n        get_gens = [\n            asyncio.create_task(pokeapi.get_generation(gen_id))\n            for gen_id in range(1, 3)\n        ]\n        get_berries = [asyncio.create_task(pokeapi.get_berry(\"cheri\"))]\n\n        await asyncio.gather(*(get_pokemons + get_gens + get_berries))\n\n    finally:\n        pokeapi.report_status(\"rich\")\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "src/tests/test_generators.py",
    "content": "from __future__ import annotations\n\nimport pytest\nimport typing as t\n\nfrom gracy import Gracy, graceful_generator\nfrom tests.conftest import REPLAY, PokeApiEndpoint\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n\n    @graceful_generator(parser={\"default\": lambda r: r.json()})\n    async def get_2_yield_graceful(self):\n        names = [\"charmander\", \"pikachu\"]\n\n        for name in names:\n            r = await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n            yield r\n\n\n@pytest.fixture()\ndef make_pokeapi():\n    def factory():\n        Gracy.dangerously_reset_report()\n        return GracefulPokeAPI(REPLAY)\n\n    return factory\n\n\nasync def test_pokemon_ok_json(make_pokeapi: t.Callable[[], GracefulPokeAPI]):\n    pokeapi = make_pokeapi()\n    count = 0\n\n    async for _ in pokeapi.get_2_yield_graceful():\n        count += 1\n\n    assert count == 2\n"
  },
  {
    "path": "src/tests/test_gracy_httpx.py",
    "content": "from __future__ import annotations\n\nimport pytest\nimport typing as t\nfrom http import HTTPStatus\n\nfrom gracy import GracefulRetry, Gracy, GracyConfig\nfrom tests.conftest import PRESENT_POKEMON_NAME, REPLAY, PokeApiEndpoint\n\nRETRY: t.Final = GracefulRetry(\n    delay=0.001,\n    max_attempts=2,\n    retry_on={HTTPStatus.NOT_FOUND},\n    behavior=\"break\",\n)\n\n\n@pytest.fixture()\ndef make_pokeapi():\n    def factory():\n        Gracy.dangerously_reset_report()\n        return GracefulPokeAPI(REPLAY)\n\n    return factory\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n        SETTINGS = GracyConfig(\n            retry=RETRY,\n            allowed_status_code={HTTPStatus.NOT_FOUND},\n            parser={HTTPStatus.NOT_FOUND: None},\n        )\n\n\nMAKE_POKEAPI_TYPE = t.Callable[[], GracefulPokeAPI]\n\n\nasync def test_pass_kwargs(make_pokeapi: MAKE_POKEAPI_TYPE):\n    pokeapi = make_pokeapi()\n\n    await pokeapi.get(\n        PokeApiEndpoint.GET_POKEMON,\n        dict(NAME=PRESENT_POKEMON_NAME),\n        follow_redirects=True,\n    )\n"
  },
  {
    "path": "src/tests/test_hooks.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport pytest\nimport typing as t\nfrom http import HTTPStatus\nfrom unittest.mock import patch\n\nfrom gracy import (\n    GracefulRetry,\n    GracefulRetryState,\n    Gracy,\n    GracyConfig,\n    GracyRequestContext,\n)\nfrom gracy.exceptions import GracyRequestFailed\nfrom tests.conftest import (\n    MISSING_NAME,\n    PRESENT_POKEMON_NAME,\n    REPLAY,\n    PokeApiEndpoint,\n    assert_requests_made,\n)\n\nRETRY: t.Final = GracefulRetry(\n    delay=0.001,\n    max_attempts=2,\n    retry_on={HTTPStatus.NOT_FOUND},\n    behavior=\"break\",\n)\n\n\n@pytest.fixture()\ndef make_pokeapi():\n    def factory():\n        Gracy.dangerously_reset_report()\n        return GracefulPokeAPI(REPLAY)\n\n    return factory\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n        SETTINGS = GracyConfig(\n            retry=RETRY,\n            allowed_status_code={HTTPStatus.NOT_FOUND},\n            parser={HTTPStatus.NOT_FOUND: None},\n        )\n\n    def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:\n        self.before_count = 0\n\n        self.after_status_counter = t.DefaultDict[int, int](int)\n        self.after_aborts = 0\n        self.after_retries_counter = 0\n\n        super().__init__(*args, **kwargs)\n\n    async def before(self, context: GracyRequestContext):\n        self.before_count += 1\n\n    async def after(\n        self,\n        context: GracyRequestContext,\n        response_or_exc: httpx.Response | Exception,\n        retry_state: GracefulRetryState | None,\n    ):\n        if retry_state:\n            self.after_retries_counter += 1\n\n        if isinstance(response_or_exc, httpx.Response):\n            self.after_status_counter[response_or_exc.status_code] += 1\n        else:\n            self.after_aborts += 1\n\n    async def get_pokemon(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n\nclass GracefulPokeAPIWithRequestHooks(GracefulPokeAPI):\n    async def before(self, context: GracyRequestContext):\n        await super().before(context)\n        # This shouldn't re-trigger any hook!\n        await self.get_pokemon(PRESENT_POKEMON_NAME)\n\n    async def after(\n        self,\n        context: GracyRequestContext,\n        response_or_exc: httpx.Response | Exception,\n        retry_state: GracefulRetryState | None,\n    ):\n        await super().after(context, response_or_exc, retry_state)\n        # This shouldn't re-trigger any hook!\n        await self.get_pokemon(PRESENT_POKEMON_NAME)\n\n\nMAKE_POKEAPI_TYPE = t.Callable[[], GracefulPokeAPI]\n\n\nasync def test_before_hook_counts(make_pokeapi: MAKE_POKEAPI_TYPE):\n    pokeapi = make_pokeapi()\n\n    assert pokeapi.before_count == 0\n    await pokeapi.get(PokeApiEndpoint.GET_POKEMON, dict(NAME=PRESENT_POKEMON_NAME))\n    assert pokeapi.before_count == 1\n    await pokeapi.get(PokeApiEndpoint.GET_POKEMON, dict(NAME=PRESENT_POKEMON_NAME))\n    assert pokeapi.before_count == 2\n\n\nasync def test_after_hook_counts_statuses(make_pokeapi: MAKE_POKEAPI_TYPE):\n    pokeapi = make_pokeapi()\n\n    assert pokeapi.after_status_counter[HTTPStatus.OK] == 0\n    assert pokeapi.after_status_counter[HTTPStatus.NOT_FOUND] == 0\n\n    await pokeapi.get(\n        PokeApiEndpoint.GET_POKEMON, dict(NAME=PRESENT_POKEMON_NAME)\n    )  # 200\n    await pokeapi.get(\n        PokeApiEndpoint.GET_POKEMON, dict(NAME=PRESENT_POKEMON_NAME)\n    )  # 200\n    await pokeapi.get(\n        PokeApiEndpoint.GET_POKEMON, dict(NAME=MISSING_NAME)\n    )  # 404 + retry 2x\n    await pokeapi.get(\n        PokeApiEndpoint.GET_POKEMON, dict(NAME=MISSING_NAME)\n    )  # 404 + retry 2x\n\n    assert pokeapi.after_status_counter[HTTPStatus.OK] == 2\n    assert pokeapi.after_status_counter[HTTPStatus.NOT_FOUND] == 6\n    assert pokeapi.after_retries_counter == 4\n\n\nasync def test_after_hook_counts_aborts():\n    Gracy.dangerously_reset_report()\n    pokeapi = GracefulPokeAPI()\n\n    class SomeRequestException(Exception):\n        pass\n\n    mock: t.Any\n    with patch.object(pokeapi, \"_client\", autospec=True) as mock:\n        mock.request.side_effect = SomeRequestException(\"Request failed\")\n\n        with pytest.raises(GracyRequestFailed):\n            await pokeapi.get_pokemon(PRESENT_POKEMON_NAME)\n\n    assert pokeapi.after_status_counter[HTTPStatus.OK] == 0\n    assert pokeapi.after_status_counter[HTTPStatus.NOT_FOUND] == 0\n    assert pokeapi.after_retries_counter == 0\n    assert pokeapi.after_aborts == 1\n\n\nasync def test_hook_has_no_recursion():\n    Gracy.dangerously_reset_report()\n    pokeapi = GracefulPokeAPIWithRequestHooks(REPLAY)\n\n    EXPECTED_REQS: t.Final = 1 + 2  # This + Before hook + After hook\n    await pokeapi.get_pokemon(PRESENT_POKEMON_NAME)\n\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n\nasync def test_hook_with_retries_has_no_recursion():\n    Gracy.dangerously_reset_report()\n    pokeapi = GracefulPokeAPIWithRequestHooks(REPLAY)\n\n    # (1 This + 2 Retries) + 2 hooks for each (3)\n    EXPECTED_REQS: t.Final = (1 + 2) + (2 * 3)\n    await pokeapi.get_pokemon(MISSING_NAME)\n\n    assert pokeapi.before_count == 3\n    assert pokeapi.after_status_counter[HTTPStatus.NOT_FOUND] == 3\n    assert pokeapi.after_retries_counter == 2\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n"
  },
  {
    "path": "src/tests/test_loggers.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport logging\nimport pytest\nimport typing as t\n\nfrom gracy import GracefulValidator, Gracy, GracyConfig, LogEvent, LogLevel\nfrom gracy.exceptions import NonOkResponse\nfrom tests.conftest import MISSING_NAME, PRESENT_POKEMON_NAME, REPLAY, PokeApiEndpoint\n\n\nclass CustomValidator(GracefulValidator):\n    def check(self, response: httpx.Response) -> None:\n        if response.json()[\"order\"] != 47:\n            raise ValueError(\"Pokemon #order should be 47\")  # noqa: TRY003\n\n\ndef assert_log(record: logging.LogRecord, expected_event: LogEvent):\n    assert record.levelno == expected_event.level\n    assert record.message == expected_event.custom_message  # No formatting set\n\n\n# NOTE: captest only captures >=warning\nON_REQUEST: t.Final = LogEvent(LogLevel.WARNING, \"LOG_REQUEST\")\nON_RESPONSE: t.Final = LogEvent(LogLevel.ERROR, \"LOG_RESPONSE\")\nON_ERROR: t.Final = LogEvent(LogLevel.CRITICAL, \"LOG_ERROR\")\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n        SETTINGS = GracyConfig(\n            log_request=ON_REQUEST,\n            log_response=ON_RESPONSE,\n            log_errors=ON_ERROR,\n        )\n\n    async def get_pokemon(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n\n@pytest.fixture()\ndef pokeapi():\n    Gracy.dangerously_reset_report()\n    return GracefulPokeAPI(REPLAY)\n\n\nasync def test_pokemon_log_request_response(\n    pokeapi: GracefulPokeAPI, caplog: pytest.LogCaptureFixture\n):\n    await pokeapi.get_pokemon(PRESENT_POKEMON_NAME)\n\n    assert len(caplog.records) == 2\n    assert_log(caplog.records[0], ON_REQUEST)\n    assert_log(caplog.records[1], ON_RESPONSE)\n\n\nasync def test_pokemon_log_request_response_error(\n    pokeapi: GracefulPokeAPI, caplog: pytest.LogCaptureFixture\n):\n    with pytest.raises(NonOkResponse):\n        await pokeapi.get_pokemon(MISSING_NAME)\n\n    assert len(caplog.records) == 3\n    assert_log(caplog.records[0], ON_REQUEST)\n    assert_log(caplog.records[1], ON_RESPONSE)\n    assert_log(caplog.records[2], ON_ERROR)\n"
  },
  {
    "path": "src/tests/test_namespaces.py",
    "content": "from __future__ import annotations\n\nimport pytest\nimport typing as t\nfrom http import HTTPStatus\n\nfrom gracy import GracefulRetry, Gracy, GracyConfig, GracyNamespace\nfrom tests.conftest import (\n    PRESENT_BERRY_NAME,\n    PRESENT_POKEMON_NAME,\n    REPLAY,\n    PokeApiEndpoint,\n    assert_muiti_endpoints_requests_made,\n)\n\nRETRY: t.Final = GracefulRetry(\n    delay=0.001,\n    max_attempts=2,\n    retry_on={HTTPStatus.NOT_FOUND},\n    behavior=\"break\",\n)\n\n\nclass PokemonNamespace(GracyNamespace[PokeApiEndpoint]):\n    async def get_one(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n\nclass BerryNamespace(GracyNamespace[PokeApiEndpoint]):\n    async def get_one(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_BERRY, {\"NAME\": name})\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n        SETTINGS = GracyConfig(\n            retry=RETRY,\n            allowed_status_code={HTTPStatus.NOT_FOUND},\n            parser={HTTPStatus.NOT_FOUND: None},\n        )\n\n    berry: BerryNamespace\n    pokemon: PokemonNamespace\n\n\n@pytest.fixture()\ndef make_pokeapi():\n    def factory():\n        Gracy.dangerously_reset_report()\n        return GracefulPokeAPI(REPLAY)\n\n    return factory\n\n\nMAKE_POKEAPI_TYPE = t.Callable[[], GracefulPokeAPI]\n\n\nasync def test_get_from_namespaces(make_pokeapi: MAKE_POKEAPI_TYPE):\n    pokeapi = make_pokeapi()\n\n    await pokeapi.pokemon.get_one(PRESENT_POKEMON_NAME)\n    await pokeapi.berry.get_one(PRESENT_BERRY_NAME)\n\n    EXPECTED_ENDPOINTS = 2\n    EXPECTED_REQUESTS = (1, 1)\n\n    assert_muiti_endpoints_requests_made(\n        pokeapi, EXPECTED_ENDPOINTS, *EXPECTED_REQUESTS\n    )\n"
  },
  {
    "path": "src/tests/test_parsers.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport pytest\nimport typing as t\nfrom http import HTTPStatus\n\nfrom gracy import Gracy, GracyConfig, graceful\nfrom gracy.exceptions import GracyParseFailed\nfrom tests.conftest import (\n    MISSING_NAME,\n    PRESENT_POKEMON_NAME,\n    REPLAY,\n    PokeApiEndpoint,\n    assert_one_request_made,\n)\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n        SETTINGS = GracyConfig(allowed_status_code=HTTPStatus.NOT_FOUND)\n\n    @graceful(parser={\"default\": lambda r: r.json()})\n    async def get_pokemon(self, name: str):\n        return await self.get[t.Dict[str, t.Any]](\n            PokeApiEndpoint.GET_POKEMON, {\"NAME\": name}\n        )\n\n    @graceful(parser={HTTPStatus.NOT_FOUND: lambda r: None})\n    async def get_pokemon_not_found_as_none(self, name: str):\n        return await self.get[t.Optional[httpx.Response]](\n            PokeApiEndpoint.GET_POKEMON, {\"NAME\": name}\n        )\n\n\n@pytest.fixture()\ndef make_pokeapi():\n    def factory():\n        Gracy.dangerously_reset_report()\n        return GracefulPokeAPI(REPLAY)\n\n    return factory\n\n\nasync def test_pokemon_ok_json(make_pokeapi: t.Callable[[], GracefulPokeAPI]):\n    pokeapi = make_pokeapi()\n\n    result: dict[str, t.Any] = await pokeapi.get_pokemon(PRESENT_POKEMON_NAME)\n\n    assert isinstance(result, dict)\n    assert \"name\" in result\n    assert result[\"name\"] == PRESENT_POKEMON_NAME\n    assert_one_request_made(pokeapi)\n\n\nasync def test_pokemon_bad_json(make_pokeapi: t.Callable[[], GracefulPokeAPI]):\n    pokeapi = make_pokeapi()\n\n    with pytest.raises(GracyParseFailed):\n        await pokeapi.get_pokemon(MISSING_NAME)\n\n    assert_one_request_made(pokeapi)\n\n\nasync def test_pokemon_not_found_as_none(make_pokeapi: t.Callable[[], GracefulPokeAPI]):\n    pokeapi = make_pokeapi()\n\n    result = await pokeapi.get_pokemon_not_found_as_none(MISSING_NAME)\n\n    assert result is None\n    assert_one_request_made(pokeapi)\n"
  },
  {
    "path": "src/tests/test_retry.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport logging\nimport pytest\nimport typing as t\nfrom http import HTTPStatus\nfrom unittest.mock import patch\n\nfrom gracy import (\n    GracefulRetry,\n    GracefulValidator,\n    Gracy,\n    GracyConfig,\n    GracyReplay,\n    LogEvent,\n    LogLevel,\n    OverrideRetryOn,\n    graceful,\n)\nfrom gracy.exceptions import GracyRequestFailed, NonOkResponse\nfrom tests.conftest import (\n    MISSING_NAME,\n    PRESENT_POKEMON_NAME,\n    REPLAY,\n    FakeReplayStorage,\n    PokeApiEndpoint,\n    assert_requests_made,\n)\n\nRETRY: t.Final = GracefulRetry(\n    delay=0.001,\n    max_attempts=0,\n    retry_on={HTTPStatus.NOT_FOUND, ValueError},\n    behavior=\"pass\",\n)\n\"\"\"NOTE: Max attempts will be patched later in fixture\"\"\"\n\nRETRY_ON_NONE: t.Final = GracefulRetry(\n    delay=0.001, max_attempts=1, retry_on=None, behavior=\"pass\"\n)\n\nRETRY_LOG_BEFORE = LogEvent(LogLevel.WARNING, \"LOG_BEFORE\")\nRETRY_LOG_AFTER = LogEvent(LogLevel.ERROR, \"LOG_AFTER\")\nRETRY_LOG_EXHAUSTED = LogEvent(LogLevel.CRITICAL, \"LOG_EXHAUSTED\")\n\nRETRY_3_TIMES_LOG: t.Final = GracefulRetry(\n    delay=0.001,\n    max_attempts=3,\n    retry_on=HTTPStatus.NOT_FOUND,\n    log_before=RETRY_LOG_BEFORE,\n    log_after=RETRY_LOG_AFTER,\n    log_exhausted=RETRY_LOG_EXHAUSTED,\n)\n\nRETRY_3_TIMES_OVERRIDE_PLACEHOLDER_LOG: t.Final = GracefulRetry(\n    delay=90,  # Will be overriden\n    max_attempts=3,\n    retry_on=HTTPStatus.NOT_FOUND,\n    overrides={HTTPStatus.NOT_FOUND: OverrideRetryOn(delay=0.001)},\n    log_before=LogEvent(LogLevel.WARNING, \"BEFORE: {RETRY_DELAY} {RETRY_CAUSE}\"),\n    log_after=LogEvent(LogLevel.WARNING, \"AFTER: {RETRY_CAUSE}\"),\n)\n\n\ndef assert_log(record: logging.LogRecord, expected_event: LogEvent):\n    assert record.levelno == expected_event.level\n    assert record.message == expected_event.custom_message  # No formatting set\n\n\nclass CustomValidator(GracefulValidator):\n    def check(self, response: httpx.Response) -> None:\n        if response.json()[\"order\"] != 47:\n            raise ValueError(\"Pokemon #order should be 47\")  # noqa: TRY003\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n        SETTINGS = GracyConfig(\n            retry=RETRY,\n            allowed_status_code={HTTPStatus.NOT_FOUND},\n            parser={HTTPStatus.NOT_FOUND: None},\n        )\n\n    async def get_pokemon(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    @graceful(retry=None)\n    async def get_pokemon_without_retry(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    @graceful(retry=None, parser=None, allowed_status_code=None)\n    async def get_pokemon_without_retry_or_parser(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    @graceful(strict_status_code={HTTPStatus.OK})\n    async def get_pokemon_with_strict_status(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    @graceful(allowed_status_code=None, parser={\"default\": lambda r: r.json()})\n    async def get_pokemon_without_allowed_status(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    @graceful(validators=CustomValidator())\n    async def get_pokemon_with_custom_validator(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    @graceful(retry=RETRY_ON_NONE)\n    async def get_pokemon_with_retry_on_none(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    @graceful(retry=RETRY_ON_NONE, validators=CustomValidator())\n    async def get_pokemon_with_retry_on_none_and_validator(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    @graceful(retry=RETRY_3_TIMES_LOG, allowed_status_code=None)\n    async def get_pokemon_with_log_retry_3_times(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    @graceful(retry=RETRY_3_TIMES_OVERRIDE_PLACEHOLDER_LOG, allowed_status_code=None)\n    async def get_pokemon_with_retry_overriden_log_placeholder(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n\n@pytest.fixture()\ndef make_pokeapi():\n    def factory(\n        max_attempts: int, break_or_pass: str = \"pass\", replay_enabled: bool = True\n    ):\n        Gracy.dangerously_reset_report()\n\n        api = GracefulPokeAPI(REPLAY) if replay_enabled else GracefulPokeAPI()\n        api._base_config.retry.max_attempts = max_attempts  # type: ignore\n        api._base_config.retry.behavior = break_or_pass  # type: ignore\n\n        return api\n\n    return factory\n\n\n@pytest.fixture()\ndef make_flaky_pokeapi():\n    def factory(\n        flaky_requests: int,\n        max_attempts: int | None = None,\n        break_or_pass: str = \"break\",\n    ):\n        Gracy.dangerously_reset_report()\n\n        force_urls = (\n            [\"https://pokeapi.co/api/v2/pokemon/doesnt-exist\"] * flaky_requests\n        ) + ([\"https://pokeapi.co/api/v2/pokemon/charmander\"])\n        mock_storage = FakeReplayStorage(force_urls)\n        fake_replay = GracyReplay(\"replay\", mock_storage)\n\n        api = GracefulPokeAPI(fake_replay)\n        if max_attempts:\n            api._base_config.retry.max_attempts = max_attempts  # type: ignore\n        api._base_config.retry.behavior = break_or_pass  # type: ignore\n\n        return api\n\n    return factory\n\n\nclass PokeApiFactory(t.Protocol):\n    def __call__(\n        self,\n        max_attempts: int,\n        break_or_pass: str = \"pass\",\n        replay_enabled: bool = True,\n    ) -> GracefulPokeAPI:\n        ...\n\n\nclass FlakyPokeApiFactory(t.Protocol):\n    def __call__(\n        self,\n        flaky_requests: int,\n        max_attempts: int | None = None,\n        break_or_pass: str = \"pass\",\n    ) -> GracefulPokeAPI:\n        ...\n\n\nasync def test_ensure_replay_is_enabled(make_pokeapi: PokeApiFactory):\n    pokeapi = make_pokeapi(0)\n    result = await pokeapi.get_pokemon(MISSING_NAME)\n    report = pokeapi.get_report()\n\n    assert result is None\n    assert report.replay_settings is not None\n    assert report.replay_settings.mode == \"replay\"\n    assert len(report.requests) == 1\n    assert report.requests[0].total_requests == 1\n\n\n@pytest.mark.parametrize(\"max_retries\", [2, 4, 6])\nasync def test_pokemon_not_found(max_retries: int, make_pokeapi: PokeApiFactory):\n    EXPECTED_REQS: t.Final = 1 + max_retries  # First request + Retries (2) = 3 requests\n\n    pokeapi = make_pokeapi(max_retries)\n    result = await pokeapi.get_pokemon(MISSING_NAME)\n\n    assert result is None\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n\n@pytest.mark.parametrize(\"max_retries\", [2, 4, 6])\nasync def test_pokemon_not_found_without_allowed(\n    max_retries: int, make_pokeapi: t.Callable[[int, str], GracefulPokeAPI]\n):\n    EXPECTED_REQS: t.Final = 1 + max_retries  # First request + Retries (2) = 3 requests\n\n    pokeapi = make_pokeapi(max_retries, \"break\")\n\n    with pytest.raises(NonOkResponse):\n        await pokeapi.get_pokemon_without_allowed_status(MISSING_NAME)\n\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n\n@pytest.mark.parametrize(\"max_retries\", [2, 4, 6])\nasync def test_pokemon_not_found_with_strict_status(\n    max_retries: int, make_pokeapi: PokeApiFactory\n):\n    EXPECTED_REQS: t.Final = 1 + max_retries  # First request + Retries (2) = 3 requests\n\n    pokeapi = make_pokeapi(max_retries)\n    result = await pokeapi.get_pokemon_with_strict_status(MISSING_NAME)\n\n    assert result is None\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n\nasync def test_pokemon_with_bad_parser_break_wont_run(make_pokeapi: PokeApiFactory):\n    MAX_RETRIES: t.Final = 2\n    EXPECTED_REQS: t.Final = 1 + MAX_RETRIES  # First request + Retries (2) = 3 requests\n\n    pokeapi = make_pokeapi(MAX_RETRIES, \"break\")\n\n    with pytest.raises(NonOkResponse):\n        await pokeapi.get_pokemon_without_allowed_status(MISSING_NAME)\n\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n\nasync def test_retry_with_failing_custom_validation(make_pokeapi: PokeApiFactory):\n    MAX_RETRIES: t.Final = 2\n    EXPECTED_REQS: t.Final = 1 + MAX_RETRIES  # First request + Retries (2) = 3 requests\n\n    pokeapi = make_pokeapi(MAX_RETRIES)\n    result = await pokeapi.get_pokemon_with_custom_validator(PRESENT_POKEMON_NAME)\n\n    assert result is not None\n\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n\nasync def test_failing_without_retry(make_pokeapi: PokeApiFactory):\n    EXPECTED_REQS: t.Final = 1\n\n    pokeapi = make_pokeapi(0)  # Won't have effect\n\n    result = await pokeapi.get_pokemon_without_retry(MISSING_NAME)\n\n    assert result is None\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n\nasync def test_failing_without_retry_or_parser(make_pokeapi: PokeApiFactory):\n    EXPECTED_REQS: t.Final = 1\n\n    pokeapi = make_pokeapi(0)  # Won't have effect\n\n    with pytest.raises(NonOkResponse):\n        await pokeapi.get_pokemon_without_retry_or_parser(MISSING_NAME)\n\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n\nasync def test_retry_none_for_successful_request(make_pokeapi: PokeApiFactory):\n    EXPECTED_REQS: t.Final = 1\n\n    pokeapi = make_pokeapi(0)  # Won't have effect\n\n    result = await pokeapi.get_pokemon_with_retry_on_none(PRESENT_POKEMON_NAME)\n\n    assert result is not None\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n\nasync def test_retry_none_for_failing_request(make_pokeapi: PokeApiFactory):\n    EXPECTED_REQS: t.Final = 2\n\n    pokeapi = make_pokeapi(0)  # Won't have effect\n\n    result = await pokeapi.get_pokemon_with_retry_on_none(MISSING_NAME)\n\n    assert result is None\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n\nasync def test_retry_none_for_failing_validator(make_pokeapi: PokeApiFactory):\n    EXPECTED_REQS: t.Final = 2\n\n    pokeapi = make_pokeapi(0)  # Won't have effect\n\n    response = await pokeapi.get_pokemon_with_retry_on_none_and_validator(\n        PRESENT_POKEMON_NAME\n    )\n\n    assert response is not None\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n\nasync def test_retry_eventually_recovers(make_flaky_pokeapi: FlakyPokeApiFactory):\n    RETRY_ATTEMPTS: t.Final = 4\n    EXPECTED_REQS: t.Final = 1 + RETRY_ATTEMPTS\n\n    # Scenario: 1 + 3 Retry attemps fail + Last attempt works\n    pokeapi = make_flaky_pokeapi(4, RETRY_ATTEMPTS)\n\n    result = await pokeapi.get_pokemon(PRESENT_POKEMON_NAME)\n\n    # Test\n    assert result is not None\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n\nasync def test_retry_eventually_recovers_with_strict(\n    make_flaky_pokeapi: FlakyPokeApiFactory,\n):\n    RETRY_ATTEMPTS: t.Final = 4\n    EXPECTED_REQS: t.Final = 1 + RETRY_ATTEMPTS\n\n    # Scenario: 1 + 3 Retry attempts fail + last attempt works\n    pokeapi = make_flaky_pokeapi(4, RETRY_ATTEMPTS)\n\n    result = await pokeapi.get_pokemon_with_strict_status(PRESENT_POKEMON_NAME)\n\n    # Test\n    assert result is not None\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n\nasync def test_retry_logs(\n    make_flaky_pokeapi: FlakyPokeApiFactory, caplog: pytest.LogCaptureFixture\n):\n    FLAKY_REQUESTS: t.Final = 3\n    EXPECTED_REQS: t.Final = FLAKY_REQUESTS + 1\n\n    pokeapi = make_flaky_pokeapi(FLAKY_REQUESTS)\n\n    result = await pokeapi.get_pokemon_with_log_retry_3_times(PRESENT_POKEMON_NAME)\n\n    # Test\n    assert result is not None\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n    assert len(caplog.records) == 6\n    assert_log(caplog.records[0], RETRY_LOG_BEFORE)\n    assert_log(caplog.records[1], RETRY_LOG_AFTER)\n    assert_log(caplog.records[2], RETRY_LOG_BEFORE)\n    assert_log(caplog.records[3], RETRY_LOG_AFTER)\n    assert_log(caplog.records[4], RETRY_LOG_BEFORE)\n    assert_log(caplog.records[5], RETRY_LOG_AFTER)\n\n\nasync def test_retry_logs_fail_reason(\n    make_flaky_pokeapi: FlakyPokeApiFactory, caplog: pytest.LogCaptureFixture\n):\n    FLAKY_REQUESTS: t.Final = 2\n    EXPECTED_REQS: t.Final = FLAKY_REQUESTS + 1\n\n    pokeapi = make_flaky_pokeapi(FLAKY_REQUESTS)\n\n    result = await pokeapi.get_pokemon_with_retry_overriden_log_placeholder(\n        PRESENT_POKEMON_NAME\n    )\n\n    # Test\n    assert result is not None\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n    assert len(caplog.records) == 4\n    assert caplog.records[0].message == \"BEFORE: 0.001 [Bad Status Code: 404]\"\n    assert caplog.records[1].message == \"AFTER: [Bad Status Code: 404]\"\n    assert caplog.records[2].message == \"BEFORE: 0.001 [Bad Status Code: 404]\"\n    assert caplog.records[3].message == \"AFTER: SUCCESSFUL\"\n\n\nasync def test_retry_logs_exhausts(\n    make_pokeapi: PokeApiFactory, caplog: pytest.LogCaptureFixture\n):\n    EXPECTED_REQS: t.Final = 3 + 1  # Retry's value from graceful + 1\n\n    pokeapi = make_pokeapi(0)  # Won't take effect due to @graceful\n\n    with pytest.raises(NonOkResponse):\n        await pokeapi.get_pokemon_with_log_retry_3_times(MISSING_NAME)\n\n    # Test\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n\n    assert len(caplog.records) == 7\n    assert_log(caplog.records[0], RETRY_LOG_BEFORE)\n    assert_log(caplog.records[1], RETRY_LOG_AFTER)\n    assert_log(caplog.records[2], RETRY_LOG_BEFORE)\n    assert_log(caplog.records[3], RETRY_LOG_AFTER)\n    assert_log(caplog.records[4], RETRY_LOG_BEFORE)\n    assert_log(caplog.records[5], RETRY_LOG_AFTER)\n    assert_log(caplog.records[6], RETRY_LOG_EXHAUSTED)\n\n\nasync def test_retry_without_replay_request_without_response_generic(\n    make_pokeapi: PokeApiFactory,\n):\n    EXPECTED_REQS: t.Final = 3 + 1\n\n    class SomeRequestException(Exception):\n        pass\n\n    # Regardless of replay being disabled, no request will be triggered as we're mocking httpx\n    pokeapi = make_pokeapi(3, break_or_pass=\"break\", replay_enabled=False)\n    pokeapi._base_config.retry.retry_on.add(GracyRequestFailed)  # type: ignore\n\n    mock: t.Any\n    with patch.object(pokeapi, \"_client\", autospec=True) as mock:\n        mock.request.side_effect = SomeRequestException(\"Request failed\")\n\n        with pytest.raises(GracyRequestFailed):\n            await pokeapi.get_pokemon(PRESENT_POKEMON_NAME)\n\n    assert_requests_made(pokeapi, EXPECTED_REQS)\n"
  },
  {
    "path": "src/tests/test_validators.py",
    "content": "from __future__ import annotations\n\nimport httpx\nimport pytest\nimport typing as t\nfrom http import HTTPStatus\n\nfrom gracy import GracefulValidator, Gracy, graceful\nfrom gracy.exceptions import NonOkResponse, UnexpectedResponse\nfrom tests.conftest import (\n    MISSING_NAME,\n    PRESENT_POKEMON_NAME,\n    REPLAY,\n    PokeApiEndpoint,\n    assert_one_request_made,\n)\n\n\nclass CustomValidator(GracefulValidator):\n    def check(self, response: httpx.Response) -> None:\n        if response.json()[\"order\"] != 47:\n            raise ValueError(\"Pokemon #order should be 47\")  # noqa: TRY003\n\n\nclass GracefulPokeAPI(Gracy[PokeApiEndpoint]):\n    class Config:\n        BASE_URL = \"https://pokeapi.co/api/v2/\"\n\n    async def get_pokemon(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    @graceful(strict_status_code=HTTPStatus.INTERNAL_SERVER_ERROR)\n    async def get_pokemon_with_wrong_strict_status(self, name: str):\n        return await self.get(PokeApiEndpoint.GET_POKEMON, {\"NAME\": name})\n\n    @graceful(strict_status_code=HTTPStatus.OK)\n    async def get_pokemon_with_correct_strict_status(self, name: str):\n        return await self.get[httpx.Response](\n            PokeApiEndpoint.GET_POKEMON, {\"NAME\": name}\n        )\n\n    @graceful(allowed_status_code=HTTPStatus.NOT_FOUND)\n    async def get_pokemon_allow_404(self, name: str):\n        return await self.get[httpx.Response](\n            PokeApiEndpoint.GET_POKEMON, {\"NAME\": name}\n        )\n\n\n@pytest.fixture()\ndef make_pokeapi():\n    def factory():\n        Gracy.dangerously_reset_report()\n        return GracefulPokeAPI(REPLAY)\n\n    return factory\n\n\nasync def test_pokemon_ok_default(make_pokeapi: t.Callable[[], GracefulPokeAPI]):\n    pokeapi = make_pokeapi()\n\n    result = t.cast(httpx.Response, await pokeapi.get_pokemon(PRESENT_POKEMON_NAME))\n\n    assert result.status_code == HTTPStatus.OK\n\n    assert_one_request_made(pokeapi)\n\n\nasync def test_pokemon_not_found_default(make_pokeapi: t.Callable[[], GracefulPokeAPI]):\n    pokeapi = make_pokeapi()\n\n    try:\n        _ = await pokeapi.get_pokemon(MISSING_NAME)\n\n    except NonOkResponse as ex:\n        assert ex.response.status_code == HTTPStatus.NOT_FOUND\n\n    else:\n        pytest.fail(\"NonOkResponse was expected\")\n\n    assert_one_request_made(pokeapi)\n\n\nasync def test_pokemon_strict_status_fail(\n    make_pokeapi: t.Callable[[], GracefulPokeAPI]\n):\n    pokeapi = make_pokeapi()\n\n    try:\n        _ = await pokeapi.get_pokemon_with_wrong_strict_status(PRESENT_POKEMON_NAME)\n\n    except UnexpectedResponse as ex:\n        assert ex.response.status_code == HTTPStatus.OK\n\n    else:\n        pytest.fail(\"UnexpectedResponse was expected\")\n\n    assert_one_request_made(pokeapi)\n\n\nasync def test_pokemon_strict_status_success(\n    make_pokeapi: t.Callable[[], GracefulPokeAPI]\n):\n    pokeapi = make_pokeapi()\n\n    result = await pokeapi.get_pokemon_with_correct_strict_status(PRESENT_POKEMON_NAME)\n\n    assert result.status_code == HTTPStatus.OK\n    assert_one_request_made(pokeapi)\n\n\nasync def test_pokemon_allow_404(make_pokeapi: t.Callable[[], GracefulPokeAPI]):\n    pokeapi = make_pokeapi()\n\n    result = await pokeapi.get_pokemon_allow_404(MISSING_NAME)\n\n    assert result.status_code == HTTPStatus.NOT_FOUND\n    assert_one_request_made(pokeapi)\n"
  },
  {
    "path": "todo.md",
    "content": "# Todo\n- [x] Strict status code\n- [x] Allowed status code\n- [x] Retry\n  - [x] Retry but pass\n  - [x] Retry logging\n- [x] Metrics (% of successful calls)\n  - [x] Status codes\n  - [x] % per status code\n  - [x] avg ms taken to execute\n- [x] Parsing\n  - [x] default = lambda r: r.json()\n  - [x] 200 = lambda r: r.text()\n  - [x] 404 = None\n  - [x] 401 = InadequatePermissions\n- [x] Throttle\n  - [x] URL regex support\n- [ ] Authorization\n  - [ ] Validate if token is still valid\n  - [ ] Auto refresh\n- [ ] Docs\n  - [x] Readme\n  - [x] Methods without `_`\n  - [ ] Contributing\n- [ ] Allow to specify status ranges\n- [x] Custom exception for parsing errors\n- [x] Replay/Record payloads\n  - [x] SQLite\n  - [ ] Mongo\n  - [ ] Custom Storage\n"
  }
]