1+ import logging
2+ from typing import Any , Optional
3+ from requests import Response
4+ import requests
5+ from requests .exceptions import RequestException
6+ from sap_cloud_sdk .dms ._auth import Auth
7+ from sap_cloud_sdk .dms .exceptions import (
8+ DMSError ,
9+ DMSConnectionError ,
10+ DMSInvalidArgumentException ,
11+ DMSObjectNotFoundException ,
12+ DMSPermissionDeniedException ,
13+ DMSRuntimeException ,
14+ )
15+ from sap_cloud_sdk .dms .model import UserClaim
16+
17+ logger = logging .getLogger (__name__ )
18+
19+
20+ class HttpInvoker :
21+ """Low-level HTTP layer. Injects auth headers and enforces timeouts."""
22+
23+ def __init__ (
24+ self ,
25+ auth : Auth ,
26+ base_url : str ,
27+ connect_timeout : int | None = None ,
28+ read_timeout : int | None = None ,
29+ ) -> None :
30+ self ._auth = auth
31+ self ._base_url = base_url .rstrip ("/" )
32+ self ._connect_timeout = connect_timeout or 10
33+ self ._read_timeout = read_timeout or 30
34+
35+ def get (
36+ self ,
37+ path : str ,
38+ tenant_subdomain : Optional [str ] = None ,
39+ headers : Optional [dict [str , str ]] = None ,
40+ user_claim : Optional [UserClaim ] = None ,
41+ ) -> Response :
42+ logger .debug ("GET %s" , path )
43+ return self ._handle (self ._execute (
44+ lambda : requests .get (
45+ f"{ self ._base_url } { path } " ,
46+ headers = self ._merged_headers (tenant_subdomain , headers , user_claim ),
47+ timeout = (self ._connect_timeout , self ._read_timeout ),
48+ )
49+ ))
50+
51+ def post (
52+ self ,
53+ path : str ,
54+ payload : dict [str , Any ],
55+ tenant_subdomain : Optional [str ] = None ,
56+ headers : Optional [dict [str , str ]] = None ,
57+ user_claim : Optional [UserClaim ] = None ,
58+ ) -> Response :
59+ logger .debug ("POST %s" , path )
60+ return self ._handle (self ._execute (
61+ lambda : requests .post (
62+ f"{ self ._base_url } { path } " ,
63+ headers = self ._merged_headers (tenant_subdomain , headers , user_claim ),
64+ json = payload ,
65+ timeout = (self ._connect_timeout , self ._read_timeout ),
66+ )
67+ ))
68+
69+ def put (
70+ self ,
71+ path : str ,
72+ payload : dict [str , Any ],
73+ tenant_subdomain : Optional [str ] = None ,
74+ headers : Optional [dict [str , str ]] = None ,
75+ user_claim : Optional [UserClaim ] = None ,
76+ ) -> Response :
77+ logger .debug ("PUT %s" , path )
78+ return self ._handle (self ._execute (
79+ lambda : requests .put (
80+ f"{ self ._base_url } { path } " ,
81+ headers = self ._merged_headers (tenant_subdomain , headers , user_claim ),
82+ json = payload ,
83+ timeout = (self ._connect_timeout , self ._read_timeout ),
84+ )
85+ ))
86+
87+ def delete (
88+ self ,
89+ path : str ,
90+ tenant_subdomain : Optional [str ] = None ,
91+ headers : Optional [dict [str , str ]] = None ,
92+ user_claim : Optional [UserClaim ] = None ,
93+ ) -> Response :
94+ logger .debug ("DELETE %s" , path )
95+ return self ._handle (self ._execute (
96+ lambda : requests .delete (
97+ f"{ self ._base_url } { path } " ,
98+ headers = self ._merged_headers (tenant_subdomain , headers , user_claim ),
99+ timeout = (self ._connect_timeout , self ._read_timeout ),
100+ )
101+ ))
102+
103+ def _execute (self , fn : Any ) -> Response :
104+ """Execute an HTTP call, wrapping network errors into DMSConnectionError."""
105+ try :
106+ return fn ()
107+ except requests .exceptions .ConnectionError as e :
108+ logger .error ("Connection error during HTTP request" )
109+ raise DMSConnectionError ("Failed to connect to the DMS service" ) from e
110+ except requests .exceptions .Timeout as e :
111+ logger .error ("Request timed out" )
112+ raise DMSConnectionError ("Request to DMS service timed out" ) from e
113+ except RequestException as e :
114+ logger .error ("Unexpected network error" )
115+ raise DMSConnectionError ("Unexpected network error" ) from e
116+
117+ def _default_headers (self , tenant_subdomain : Optional [str ] = None ) -> dict [str , str ]:
118+ return {
119+ "Authorization" : f"Bearer { self ._auth .get_token (tenant_subdomain )} " ,
120+ "Content-Type" : "application/json" ,
121+ "Accept" : "application/json" ,
122+ }
123+
124+ def _user_claim_headers (self , user_claim : Optional [UserClaim ]) -> dict [str , str ]:
125+ if not user_claim :
126+ return {}
127+ headers : dict [str , str ] = {}
128+ if user_claim .x_ecm_user_enc :
129+ headers ["X-EcmUserEnc" ] = user_claim .x_ecm_user_enc
130+ if user_claim .x_ecm_add_principals :
131+ headers ["X-EcmAddPrincipals" ] = ";" .join (user_claim .x_ecm_add_principals )
132+ return headers
133+
134+ def _merged_headers (
135+ self ,
136+ tenant_subdomain : Optional [str ],
137+ overrides : Optional [dict [str , str ]],
138+ user_claim : Optional [UserClaim ] = None ,
139+ ) -> dict [str , str ]:
140+ return {
141+ ** self ._default_headers (tenant_subdomain ),
142+ ** self ._user_claim_headers (user_claim ),
143+ ** (overrides or {}),
144+ }
145+
146+ def _handle (self , response : Response ) -> Response :
147+ logger .debug ("Response status: %s" , response .status_code )
148+ if response .status_code in (200 , 201 , 204 ):
149+ return response
150+
151+ # error_content kept for debugging but not surfaced in the exception message
152+ error_content = response .text
153+ logger .warning ("Request failed with status %s" , response .status_code )
154+
155+ match response .status_code :
156+ case 400 :
157+ raise DMSInvalidArgumentException (
158+ "Request contains invalid or disallowed parameters" , 400 , error_content
159+ )
160+ case 401 | 403 :
161+ raise DMSPermissionDeniedException (
162+ "Access denied — invalid or expired token" , response .status_code , error_content
163+ )
164+ case 404 :
165+ raise DMSObjectNotFoundException (
166+ "The requested resource was not found" , 404 , error_content
167+ )
168+ case 500 :
169+ raise DMSRuntimeException (
170+ "The DMS service encountered an internal error" , 500 , error_content
171+ )
172+ case _:
173+ raise DMSError (
174+ f"Unexpected response from DMS service : " + error_content , response .status_code , error_content
175+ )
0 commit comments