11import logging
2+ import time
23from typing import Any
34
45import httpx
6+ from httpx import Response
57
68from .exceptions import (
79 MoneybirdAPIError ,
1921admin_id_ = 0
2022token_ = ""
2123timeout_ = 20
24+ max_retries_ = 3
2225
2326
2427def set_admin_id (admin_id : int ) -> None :
@@ -36,6 +39,22 @@ def set_timeout(timeout: int) -> None:
3639 timeout_ = timeout
3740
3841
42+ def set_max_retries (max_retries : int ) -> None :
43+ global max_retries_
44+ max_retries_ = max_retries
45+
46+
47+ _IDEMPOTENT_METHODS = frozenset ({"get" , "put" , "delete" , "head" , "options" })
48+
49+
50+ def _is_retryable (status_code : int , method : str ) -> bool :
51+ if status_code == 429 :
52+ return True
53+ if status_code >= 500 and method .lower () in _IDEMPOTENT_METHODS :
54+ return True
55+ return False
56+
57+
3958def make_request (
4059 path : str ,
4160 data : dict [str , Any ] | None = None ,
@@ -47,49 +66,101 @@ def make_request(
4766 "Content-Type" : "application/json" ,
4867 }
4968 fullpath = f"{ MB_URL } /{ MB_VERSION_ID } /{ admin_id_ } /{ path } "
50- logger .debug ("%s %s" , method .upper (), fullpath )
51- response = httpx .request (
52- method ,
53- fullpath ,
54- json = data ,
55- headers = headers ,
56- timeout = timeout_ ,
57- params = params ,
58- )
59-
60- try :
61- response .raise_for_status ()
62- except httpx .HTTPStatusError as exc :
63- body = response .text
64- logger .warning (
65- "%s %s returned %d: %s" ,
66- method .upper (),
69+
70+ last_exc : httpx .HTTPStatusError | None = None
71+ for attempt in range (max_retries_ + 1 ):
72+ logger .debug ("%s %s (attempt %d)" , method .upper (), fullpath , attempt + 1 )
73+ response = httpx .request (
74+ method ,
6775 fullpath ,
68- response .status_code ,
69- body ,
76+ json = data ,
77+ headers = headers ,
78+ timeout = timeout_ ,
79+ params = params ,
7080 )
71- error_cls : type [MoneybirdAPIError ] = {
72- 404 : MoneybirdNotFoundError ,
73- 422 : MoneybirdValidationError ,
74- 429 : MoneybirdRateLimitError ,
75- }.get (response .status_code , MoneybirdAPIError )
76- raise error_cls (
77- status_code = response .status_code ,
78- response_body = body ,
79- method = method ,
80- path = path ,
81- ) from exc
8281
83- logger .debug ("%s %s returned %d" , method .upper (), fullpath , response .status_code )
84-
85- # return json if there is content
86- return response .json () if response .content else {}
82+ try :
83+ response .raise_for_status ()
84+ except httpx .HTTPStatusError as exc :
85+ last_exc = exc
86+ if _is_retryable (response .status_code , method ) and attempt < max_retries_ :
87+ retry_after = response .headers .get ("Retry-After" )
88+ if retry_after :
89+ try :
90+ delay = int (retry_after )
91+ except ValueError :
92+ delay = 2 ** attempt
93+ delay = max (delay , 1 )
94+ else :
95+ delay = 2 ** attempt
96+ logger .warning (
97+ "%s %s returned %d, retrying in %ds (attempt %d/%d)" ,
98+ method .upper (),
99+ fullpath ,
100+ response .status_code ,
101+ delay ,
102+ attempt + 1 ,
103+ max_retries_ + 1 ,
104+ )
105+ time .sleep (delay )
106+ continue
107+
108+ body = response .text
109+ logger .warning (
110+ "%s %s returned %d: %s" ,
111+ method .upper (),
112+ fullpath ,
113+ response .status_code ,
114+ body ,
115+ )
116+ error_cls : type [MoneybirdAPIError ] = {
117+ 404 : MoneybirdNotFoundError ,
118+ 422 : MoneybirdValidationError ,
119+ 429 : MoneybirdRateLimitError ,
120+ }.get (response .status_code , MoneybirdAPIError )
121+ raise error_cls (
122+ status_code = response .status_code ,
123+ response_body = body ,
124+ method = method ,
125+ path = path ,
126+ ) from exc
127+
128+ logger .debug ("%s %s returned %d" , method .upper (), fullpath , response .status_code )
129+ return response .json () if response .content else {}
130+
131+ # All retries exhausted — raise from last exception
132+ assert last_exc is not None
133+ body = last_exc .response .text
134+ status_code = last_exc .response .status_code
135+ error_cls = {
136+ 404 : MoneybirdNotFoundError ,
137+ 422 : MoneybirdValidationError ,
138+ 429 : MoneybirdRateLimitError ,
139+ }.get (status_code , MoneybirdAPIError )
140+ raise error_cls (
141+ status_code = status_code ,
142+ response_body = body ,
143+ method = method ,
144+ path = path ,
145+ ) from last_exc
87146
88147
89148def http_get (path : str , params : dict [str , Any ] | None = None ) -> Any :
90149 return make_request (path , method = "get" , params = params )
91150
92151
152+ def http_get_raw (path : str ) -> Response :
153+ """Perform a GET and return the raw httpx Response (for binary downloads)."""
154+ headers = {
155+ "Authorization" : f"Bearer { token_ } " ,
156+ }
157+ fullpath = f"{ MB_URL } /{ MB_VERSION_ID } /{ admin_id_ } /{ path } "
158+ logger .debug ("GET %s (raw)" , fullpath )
159+ response = httpx .get (fullpath , headers = headers , timeout = timeout_ )
160+ response .raise_for_status ()
161+ return response
162+
163+
93164def http_post (path : str , data : dict [str , Any ] | None = None ) -> Any :
94165 return make_request (path , method = "post" , data = data )
95166
0 commit comments