Package: gracy
Python's most graceful API Client FrameworkGracy handles failures, logging, retries, throttling, parsing, and reporting for all your HTTP interactions. Gracy useshttpxunder the hood."Let Gracy do the boring stuff while you focus on your application"Summary🧑‍💻 Get startedInstallationUsageSimple exampleMore examplesSettingsStrict/Allowed status codeCustom ValidatorsParsingParsing TypingRetryThrottlingConcurrent RequestsLoggingCustom ExceptionsReportsLoggerListTablePlotlyReplay requestsRecordingReplayResource NamespacingPaginationAdvanced UsageCustomizing/Overriding configs per methodCustomizing HTTPx clientOverriding default request timeoutCreating a custom Replay data sourceHooks before/after requestCommon HooksHttpHeaderRetryAfterBackOffHookRateLimitBackOffHook📚 Extra ResourcesChange logLicenseCredits🧑‍💻 Get startedInstallationpip install gracyORpoetry add gracyUsageExamples will be shown using thePokeAPI.Simple example# 0. ImportimportasyncioimporttypingastfromgracyimportBaseEndpoint,Gracy,GracyConfig,LogEvent,LogLevel# 1. Define your endpointsclassPokeApiEndpoint(BaseEndpoint):GET_POKEMON="/pokemon/{NAME}"# 👈 Put placeholders as needed# 2. Define your Graceful APIclassGracefulPokeAPI(Gracy[str]):classConfig:BASE_URL="https://pokeapi.co/api/v2/"# 👈 Optional BASE_URL# 👇 Define settings to apply for every requestSETTINGS=GracyConfig(log_request=LogEvent(LogLevel.DEBUG),log_response=LogEvent(LogLevel.INFO,"{URL}took{ELAPSED}"),parser={"default":lambdar:r.json()})asyncdefget_pokemon(self,name:str)->t.Awaitable[dict]:returnawaitself.get(PokeApiEndpoint.GET_POKEMON,{"NAME":name})pokeapi=GracefulPokeAPI()asyncdefmain():try:pokemon=awaitpokeapi.get_pokemon("pikachu")print(pokemon)finally:pokeapi.report_status("rich")asyncio.run(main())More examplesPokeAPI with retries, parsers, logsPokeAPI with throttlingPokeAPI with SQLite replayPokeAPI with Mongo replaySettingsStrict/Allowed status codeBy default Gracy considers any successful status code (200-299) as successful.StrictYou can modify this behavior by defining a strict status code or increase the range of allowed status codes:fromhttpimportHTTPStatusGracyConfig(strict_status_code=HTTPStatus.CREATED)or a list of values:fromhttpimportHTTPStatusGracyConfig(strict_status_code={HTTPStatus.OK,HTTPStatus.CREATED})Usingstrict_status_codemeans that any other code not specified will raise an error regardless of being successful or not.AllowedYou can also keep the behavior, but extend the range of allowed codes.fromhttpimportHTTPStatusGracyConfig(allowed_status_code=HTTPStatus.NOT_FOUND)or a list of valuesfromhttpimportHTTPStatusGracyConfig(allowed_status_code={HTTPStatus.NOT_FOUND,HTTPStatus.FORBIDDEN})Usingallowed_status_codemeans that all successful codes plus your defined codes will be considered successful.This is quite useful for parsing as you'll see soon.⚠️ Note thatstrict_status_codetakes precedence overallowed_status_code, probably you don't want to combine those. Prefer one or the other.Custom ValidatorsYou 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).fromgracyimportGracefulValidatorclassMyException(Exception):passclassMyCustomValidator(GracefulValidator):defcheck(self,response:httpx.Response)->None:jsonified=response.json()ifjsonified.get('error',None):raiseMyException("Error is not expected")returnNone...classConfig:SETTINGS=GracyConfig(...,retry=GracefulRetry(retry_on=MyException,...),# Set up retry to work whenever our validator failsvalidators=MyCustomValidator(),# Set up validator)ParsingParsing allows you to handle the request based on the status code returned.The basic example is parsingjson:GracyConfig(parser={"default":lambdar:r.json()})In this example all successful requests will automatically return thejson()result.You can also narrow it down to handle specific status codes.classConfig:SETTINGS=GracyConfig(...,allowed_status_code=HTTPStatusCode.NOT_FOUND,parser={"default":lambdar:r.json()HTTPStatusCode.NOT_FOUND:None})asyncdefget_pokemon(self,name:str)->dict|None:# 👇 Returns either dict or Nonereturnawaitself.get(PokeApiEndpoint.GET_POKEMON,{"NAME":name})Or even customizeexceptions to improve your code readability:classPokemonNotFound(GracyUserDefinedException):...# More on exceptions belowclassConfig:GracyConfig(...,allowed_status_code=HTTPStatusCode.NOT_FOUND,parser={"default":lambdar:r.json()HTTPStatusCode.NOT_FOUND:PokemonNotFound})asyncdefget_pokemon(self,name:str)->Awaitable[dict]:# 👇 Returns either dict or raises PokemonNotFoundreturnawaitself.get(PokeApiEndpoint.GET_POKEMON,{"NAME":name})Parsing TypingBecause parsers allow you to dynamically parse a payload based on the status code your IDE will not identify the return type by itself.To avoid boringtyping.castfor every method, Gracy provides typed http methods, so you can define a specific return type:asyncdeflist(self,offset:int=0,limit:int=20):params=dict(offset=offset,limit=limit)returnawaitself.get[ResourceList](# Specifies this method return a `ResourceList`PokeApiEndpoint.BERRY_LIST,params=params)asyncdefget_one(self,name_or_id:str|int):returnawaitself.get[models.Berry|None](PokeApiEndpoint.BERRY_GET,params=dict(KEY=str(name_or_id)))RetryWho doesn't hate flaky APIs? 🙋Yet there're many of them.Using tenacity, backoff, retry, aiohttp_retry, and any other retry libs isNOT easy enough. 🙅You still would need to code the implementation for each request which is annoying.Here's how Gracy allows you to implement your retry logic:classConfig:GracyConfig(retry=GracefulRetry(delay=1,max_attempts=3,delay_modifier=1.5,retry_on=None,log_before=None,log_after=LogEvent(LogLevel.WARNING),log_exhausted=LogEvent(LogLevel.CRITICAL),behavior="break",))ParameterDescriptionExampledelayHow many seconds to wait between retries2would wait 2 seconds,1.5would wait 1.5 seconds, and so onmax_attemptsHow many times should Gracy retry the request?10means 1 regular request with additional 10 retries in case they keep failing.1should be the minimumdelay_modifierAllows you to specify increasing delay times by multiplying this value todelaySetting1means no delay change. Setting2means delay will be doubled every retryretry_onShould we retry for which status codes/exceptions?Nonemeans for any non successful status code or exceptionHTTPStatus.BAD_REQUEST, or{HTTPStatus.BAD_REQUEST, HTTPStatus.FORBIDDEN}, orExceptionor{Exception, HTTPStatus.NOT_FOUND}log_beforeSpecify log level.Nonemeans don't logMore on logging laterlog_afterSpecify log level.Nonemeans don't logMore on logging laterlog_exhaustedSpecify log level.Nonemeans don't logMore on logging laterbehaviorAllows you to define how to deal if the retry fails.passwill accept any retry failurepassorbreak(default)overridesAllows to overridedelaybased on last response status code{HTTPStatus.BAD_REQUEST: OverrideRetryOn(delay=0), HTTPStatus.INTERNAL_SERVER_ERROR: OverrideRetryOn(delay=10)}ThrottlingRate limiting issues? No more.Gracy helps you proactively deal with it before any API throws 429 in your face.Creating rulesYou can define rules per endpoint using regex:SIMPLE_RULE=ThrottleRule(url_pattern=r".*",max_requests=2)print(SIMPLE_RULE)# Output: "2 requests per second for URLs matching re.compile('.*')"COMPLEX_RULE=ThrottleRule(url_pattern=r".*\/pokemon\/.*",max_requests=10,per_time=timedelta(minutes=1,seconds=30),)print(COMPLEX_RULE)# Output: 10 requests per 90 seconds for URLs matching re.compile('.*\\/pokemon\\/.*')Setting throttlingYou can set up logging and assign rules as:classConfig:GracyConfig(throttling=GracefulThrottle(rules=ThrottleRule(r".*",2),# 2 reqs/s for any endpointlog_limit_reached=LogEvent(LogLevel.ERROR),log_wait_over=LogEvent(LogLevel.WARNING),),)Concurrent RequestsMaybe 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.You can define aConcurrentRequestLimitconfig.The simplest usage is:fromgracyimportConcurrentRequestLimitclassConfig:GracyConfig(concurrent_requests=ConcurrentRequestLimit(limit=1,# How many concurrent requestslog_limit_reached=LogEvent(LogLevel.WARNING),log_limit_freed=LogEvent(LogLevel.INFO),),)But you can also define it easily per method as:classMyApiClient(Gracy[Endpoint]):@graceful(concurrent_requests=5)asyncdefget_concurrently_five(self,name:str):...LoggingYou candefine and customize logsfor events by usingLogEventandLogLevel:verbose_log=LogEvent(LogLevel.CRITICAL)custom_warn_log=LogEvent(LogLevel.WARNING,custom_message="{METHOD}{URL}is quite slow and flaky")custom_error_log=LogEvent(LogLevel.INFO,custom_message="{URL}returned a bad status code{STATUS}, but that's fine")Note that placeholders are formatted and replaced later on by Gracy based on the event type, like:Placeholders per eventPlaceholderDescriptionExampleSupported Events{URL}Full url being targettedhttps://pokeapi.co/api/v2/pokemon/pikachuAll{UURL}FullUnformattedurl being targettedhttps://pokeapi.co/api/v2/pokemon/{NAME}All{ENDPOINT}Endpoint being targetted/pokemon/pikachuAll{UENDPOINT}Unformattedendpoint being targetted/pokemon/{NAME}All{METHOD}HTTP Request being usedGET,POSTAll{STATUS}Status code returned by the response200,404,501After Request{ELAPSED}Amount of seconds taken for the request to completeNumericAfter Request{REPLAY}A placeholder that is displayed only when request is replayedREPLAYEDwhen replay, otherwise it's a blank str (``)After Request{IS_REPLAY}Boolean value to show whether it's replayed or notString withTRUEwhen replayed orFALSEAfter Request{RETRY_DELAY}How long Gracy will wait before repeating the requestNumericAny Retry event{RETRY_CAUSE}What caused the retry logic to trigger[Bad Status Code: 404],[Request Error: ConnectionTimeout]Any Retry event{CUR_ATTEMPT}Current attempt count for the current requestNumericAny Retry event{MAX_ATTEMPT}Max attempt defined for the current requestNumericAny Retry event{THROTTLE_LIMIT}How many reqs/s is defined for the current requestNumericAny Throttle event{THROTTLE_TIME}How long Gracy will wait before calling the requestNumericAny Throttle event{THROTTLE_TIME_RANGE}Time range defined by the throttling rulesecond,90 secondsAny Throttle eventand you can set up the log events as follows:RequestsBefore requestAfter responseResponse has non successful errorsGracyConfig(log_request=LogEvent(),log_response=LogEvent(),log_errors=LogEvent(),)RetryBefore retryAfter retryWhen retry exhaustedGracefulRetry(...,log_before=LogEvent(),log_after=LogEvent(),log_exhausted=LogEvent(),)ThrottlingWhen reqs/s limit is reachedWhen limit decreases againGracefulThrottle(...,log_limit_reached=LogEvent()log_wait_over=LogEvent())Dynamic CustomizationYou can customize it even further by passing a lambda:LogEvent(LogLevel.ERROR,lambdar:"Request failed with{STATUS}"f" and it was{'redirected'ifr.is_redirectelse'NOT redirected'}"ifrelse"",)Consider that:Not all log events have the response available, so you need to guard yourself against itPlaceholders still works (e.g.{STATUS})You need to watch out for some attrs that might break the formatting logic (e.g.r.headers)Custom ExceptionsYou can define custom exceptions for morefine grained control over your exception messages/types.The simplest you can do is:fromgracyimportGracy,GracyConfigfromgracy.exceptionsimportGracyUserDefinedExceptionclassMyCustomException(GracyUserDefinedException):passclassMyApi(Gracy[str]):classConfig:SETTINGS=GracyConfig(...,parser={HTTPStatus.BAD_REQUEST:MyCustomException})This will raise your custom exception under the conditions defined in your parser.You can improve it even further by customizing your message:classPokemonNotFound(GracyUserDefinedException):BASE_MESSAGE="Unable to find a pokemon with the name [{NAME}] at{URL}due to{STATUS}status"def_format_message(self,request_context:GracyRequestContext,response:httpx.Response)->str:format_args=self._build_default_args()name=request_context.endpoint_args.get("NAME","Unknown")returnself.BASE_MESSAGE.format(NAME=name,**format_args)ReportsLoggerRecommended for production environments.Gracy reports a short summary usinglogger.info.pokeapi=GracefulPokeAPI()# do stuff with your APIpokeapi.report_status("logger")# OUTPUT❯Gracytrackedthat'https://pokeapi.co/api/v2/pokemon/{NAME}'washit1time(s)withasuccessrateof100.00%,avglatencyof0.45s,andarateof1.0reqs/s.❯Gracytrackedatotalof2requestswithasuccessrateof100.00%,avglatencyof0.24s,andarateof1.0reqs/s.ListUsesprintto generate a short list with all attributes:pokeapi=GracefulPokeAPI()# do stuff with your APIpokeapi.report_status("list")# OUTPUT____/___|___________||_|'__/ _` |/ __| | | |||_||||(_||(__||_||\____|_|\__,_|\___|\__,||___/RequestsSummaryReport1.https://pokeapi.co/api/v2/pokemon/{NAME}TotalReqs(#): 1Success(%):100.00%Fail(%):0.00%AvgLatency(s):0.39MaxLatency(s):0.392xxResps:13xxResps:04xxResps:05xxResps:0AvgReqs/sec:1.0reqs/s2.https://pokeapi.co/api/v2/generation/{ID}/TotalReqs(#): 1Success(%):100.00%Fail(%):0.00%AvgLatency(s):0.04MaxLatency(s):0.042xxResps:13xxResps:04xxResps:05xxResps:0AvgReqs/sec:1.0reqs/sTOTALTotalReqs(#): 2Success(%):100.00%Fail(%):0.00%AvgLatency(s):0.21MaxLatency(s):0.002xxResps:23xxResps:04xxResps:05xxResps:0AvgReqs/sec:1.0reqs/sTableIt requires you to installRich.pokeapi=GracefulPokeAPI()# do stuff with your APIpokeapi.report_status("rich")Here's an example of how it looks:PlotlyIt requires you to installplotly 📊andpandas 🐼.pokeapi=GracefulPokeAPI()# do stuff with your APIplotly_fig=pokeapi.report_status("plotly")plotly_fig.show()Here's an example of how it looks:Replay requestsGracy allows you to replay requests and responses from previous interactions.This 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.It works in two steps:StepDescriptionHits the API?1. RecordingStores all requests/responses to be later replayedYes2. ReplayReturns all previously generated responses based on your request as a "replay"NoRecordingThe effort to record requests/responses is ZERO. You just need to pass a recording config to your Graceful API:fromgracyimportGracyReplayfromgracy.replays.storages.sqliteimportSQLiteReplayStoragerecord_mode=GracyReplay("record",SQLiteReplayStorage("pokeapi.sqlite3"))pokeapi=GracefulPokeAPI(record_mode)Every requestwill be recorded to the defined data source.ReplayOnce you have recorded all your requests you can enable the replay mode:fromgracyimportGracyReplayfromgracy.replays.storages.sqliteimportSQLiteReplayStoragereplay_mode=GracyReplay("replay",SQLiteReplayStorage("pokeapi.sqlite3"))pokeapi=GracefulPokeAPI(replay_mode)Every requestwill be routed to the defined data source resulting in faster responses.⚠️ Note that parsers, retries, throttling, and similar configs will work as usual.Resource NamespacingYou can have multiple namespaces to organize your API endpoints as you wish.To do so, you just have to inherit fromGracyNamespaceand instantiate it within theGracyAPI:fromgracyimportGracy,GracyNamespace,GracyConfigclassPokemonNamespace(GracyNamespace[PokeApiEndpoint]):asyncdefget_one(self,name:str):returnawaitself.get(PokeApiEndpoint.GET_POKEMON,{"NAME":name})classBerryNamespace(GracyNamespace[PokeApiEndpoint]):asyncdefget_one(self,name:str):returnawaitself.get(PokeApiEndpoint.GET_BERRY,{"NAME":name})classGracefulPokeAPI(Gracy[PokeApiEndpoint]):classConfig:BASE_URL="https://pokeapi.co/api/v2/"SETTINGS=GracyConfig(retry=RETRY,allowed_status_code={HTTPStatus.NOT_FOUND},parser={HTTPStatus.NOT_FOUND:None},)# These will be automatically assigned on initberry:BerryNamespacepokemon:PokemonNamespaceAnd the usage will work as:awaitpokeapi.pokemon.get_one("pikachu")awaitpokeapi.berry.get_one("cheri")Note all configs are propagated to namespaces, but namespaces can still have their own which would cause merges when instantiatedg.PaginationThere're endpoints that may require pagination. For that you can useGracyPaginator.For a simple case where you passoffsetandlimit, you can useGracyOffsetPaginator:fromgracyimportGracyOffsetPaginatorclassBerryNamespace(GracyNamespace[PokeApiEndpoint]):@parsed_response(ResourceList)asyncdeflist(self,offset:int=0,limit:int=20):params=dict(offset=offset,limit=limit)returnawaitself.get(PokeApiEndpoint.BERRY_LIST,params=params)defpaginate(self,limit:int=20)->GracyOffsetPaginator[ResourceList]:returnGracyOffsetPaginator[ResourceList](gracy_func=self.list,has_next=lambdar:bool(r["next"])ifrelseTrue,page_size=limit,)and then use it as:asyncdefmain():api=PokeApi()paginator=api.berry.paginate(2)# Just grabs the next pagefirst=awaitpaginator.next_page()print(first)# Resets current page to 0paginator.set_page(0)# Loop throught it allasyncforpageinpaginator:print(page)Advanced UsageCustomizing/Overriding configs per methodAPIs may return different responses/conditions/payloads based on the endpoint.You can override anyGracyConfigon a per method basis by using the@gracefuldecorator.NOTE: Use@graceful_generatorif your function usesyield.fromgracyimportGracy,GracyConfig,GracefulRetry,graceful,graceful_generatorretry=GracefulRetry(...)classGracefulPokeAPI(Gracy[PokeApiEndpoint]):classConfig:BASE_URL="https://pokeapi.co/api/v2/"SETTINGS=GracyConfig(retry=retry,log_errors=LogEvent(LogLevel.ERROR,"How can I become a pokemon master if{URL}keeps failing with{STATUS}"),)@graceful(retry=None,# 👈 Disables retry set in Configlog_errors=None,# 👈 Disables log_errors set in Configallowed_status_code=HTTPStatus.NOT_FOUND,parser={"default":lambdar:r.json()["order"],HTTPStatus.NOT_FOUND:None,},)asyncdefmaybe_get_pokemon_order(self,name:str):val:str|None=awaitself.get(PokeApiEndpoint.GET_POKEMON,{"NAME":name})returnval@graceful(# 👈 Retry and log_errors are still set for this onestrict_status_code=HTTPStatus.OK,parser={"default":lambdar:r.json()["order"]},)asyncdefget_pokemon_order(self,name:str):val:str=awaitself.get(PokeApiEndpoint.GET_POKEMON,{"NAME":name})returnval@graceful_generator(# 👈 Retry and log_errors are still set for this oneparser={"default":lambdar:r.json()["order"]},)asyncdefget_2_pokemons(self):names=["charmander","pikachu"]fornameinnames:r=awaitself.get(PokeApiEndpoint.GET_POKEMON,{"NAME":name})yieldrCustomizing HTTPx clientYou might want to modify the HTTPx client settings, do so by:classYourAPIClient(Gracy[str]):classConfig:...def__init__(self,token:token)->None:self._token=tokensuper().__init__()# 👇 Implement your logic heredef_create_client(self)->httpx.AsyncClient:client=super()._create_client()client.headers={"Authorization":f"token{self._token}"}# type: ignorereturnclientOverriding default request timeoutAs default Gracy won't enforce a request timeout.You can define your own by setting it on Config as:classGracefulAPI(GracyApi[str]):classConfig:BASE_URL="https://example.com"REQUEST_TIMEOUT=10.2# 👈 HereCreating a custom Replay data sourceGracy was built with extensibility in mind.You can create your own storage to store/load anywhere (e.g. SQL Database), here's an example:importhttpxfromgracyimportGracyReplayStorageclassMyCustomStorage(GracyReplayStorage):defprepare(self)->None:# (Optional) Executed upon API instance creation....asyncdefrecord(self,response:httpx.Response)->None:...# REQUIRED. Your logic to store the response object. Note the httpx.Response has request data.asyncdef_load(self,request:httpx.Request)->httpx.Response:...# REQUIRED. Your logic to load a response object based on the request.# Usagerecord_mode=GracyReplay("record",MyCustomStorage())replay_mode=GracyReplay("replay",MyCustomStorage())pokeapi=GracefulPokeAPI(record_mode)Hooks before/after requestYou can set up hooks simply by definingasync def beforeandasync def aftermethods.⚠️ NOTE: Gracy configs are disabled within these methods which means that retries/parsers/throttling won't take effect inside it.classGracefulPokeAPI(Gracy[PokeApiEndpoint]):classConfig:BASE_URL="https://pokeapi.co/api/v2/"SETTINGS=GracyConfig(retry=RETRY,allowed_status_code={HTTPStatus.NOT_FOUND},parser={HTTPStatus.NOT_FOUND:None},)def__init__(self,*args:t.Any,**kwargs:t.Any)->None:self.before_count=0self.after_status_counter=defaultdict[HTTPStatus,int](int)self.after_aborts=0self.after_retries_counter=0super().__init__(*args,**kwargs)asyncdefbefore(self,context:GracyRequestContext):self.before_count+=1asyncdefafter(self,context:GracyRequestContext,# Current request contextresponse_or_exc:httpx.Response|Exception,# Either the request or an errorretry_state:GracefulRetryState|None,# Set when this is generated from a retry):ifretry_state:self.after_retries_counter+=1ifisinstance(response_or_exc,httpx.Response):self.after_status_counter[HTTPStatus(response_or_exc.status_code)]+=1else:self.after_aborts+=1asyncdefget_pokemon(self,name:str):returnawaitself.get(PokeApiEndpoint.GET_POKEMON,{"NAME":name})In the example above invokingget_pokemon()will triggerbefore()/after()hooks in sequence.Common HooksHttpHeaderRetryAfterBackOffHookThis hook checks for 429 (TOO MANY REQUESTS), and then reads theretry-afterheader.If the value is set, then Gracy pausesALLclient requests until the time is over. This behavior can be modified to happen on a per-endpoint basis iflock_per_endpointis True.Example Usage:fromgracy.common_hooksimportHttpHeaderRetryAfterBackOffHookclassGracefulAPI(GracyAPI[Endpoint]):def__init__(self):self._retry_after_hook=HttpHeaderRetryAfterBackOffHook(self._reporter,lock_per_endpoint=True,log_event=LogEvent(LogLevel.WARNING,custom_message=("{ENDPOINT}produced{STATUS}and requested to wait{RETRY_AFTER}s ""- waiting{RETRY_AFTER_ACTUAL_WAIT}s"),),# Wait +10s to avoid this from happening again too soonseconds_processor=lambdasecs_requested:secs_requested+10,)super().__init__()asyncdefbefore(self,context:GracyRequestContext):awaitself._retry_after_hook.before(context)asyncdefafter(self,context:GracyRequestContext,response_or_exc:httpx.Response|Exception,retry_state:GracefulRetryState|None,):retry_after_result=awaitself._retry_after_hook.after(context,response_or_exc)RateLimitBackOffHookThis hook checks for 429 (TOO MANY REQUESTS) and locks requests for an arbitrary amount of time defined by you.If the value is set, then Gracy pausesALLclient requests until the time is over.
This behavior can be modified to happen on a per-endpoint basis iflock_per_endpointis True.fromgracy.common_hooksimportRateLimitBackOffHookclassGracefulAPI(GracyAPI[Endpoint]):def__init__(self):self._ratelimit_backoff_hook=RateLimitBackOffHook(30,self._reporter,lock_per_endpoint=True,log_event=LogEvent(LogLevel.INFO,custom_message="{UENDPOINT}got rate limited, waiting for{WAIT_TIME}s",),)super().__init__()asyncdefbefore(self,context:GracyRequestContext):awaitself._ratelimit_backoff_hook.before(context)asyncdefafter(self,context:GracyRequestContext,response_or_exc:httpx.Response|Exception,retry_state:GracefulRetryState|None,):backoff_result=awaitself._ratelimit_backoff_hook.after(context,response_or_exc)fromgracy.common_hooksimportHttpHeaderRetryAfterBackOffHook,RateLimitBackOffHook📚 Extra ResourcesSome good practices I learned over the past years guided Gracy's philosophy, you might benefit by reading:How to logHow to handle exceptionsHow to structure exceptionsHow to use Async correctlyBook: Python like a PROBook: Effective PythonChange logSeeCHANGELOG.LicenseMITCreditsThanks 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.Most 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. 🙌Also, thanks to thehttpxandrichprojects for the beautiful and simple APIs that powers Gracy.