diff --git a/scripts/generate_resources.py b/scripts/generate_resources.py index 5a8c46e2..0abb8254 100644 --- a/scripts/generate_resources.py +++ b/scripts/generate_resources.py @@ -294,6 +294,7 @@ def generate_method_body( body_params = [p for p in params if p["in"] == "body"] raw_body_params = [p for p in params if p["in"] == "raw_body"] path_params = [p for p in params if p["in"] == "path"] + header_params = [p for p in params if p["in"] == "header"] # Handle path parameters path_expr = f'"{path}"' @@ -314,6 +315,9 @@ def generate_method_body( use_query_params = http_method.upper() in ("GET", "DELETE") and query_params use_body_params = http_method.upper() in ("POST", "PUT", "PATCH") and body_params use_query_on_post = http_method.upper() in ("POST", "PUT", "PATCH") and query_params + # Only _get and _post accept a `headers` kwarg (see BaseClient); no + # operation on PUT/PATCH/DELETE currently has a header param. + use_headers = http_method.upper() in ("GET", "POST") and header_params # Build params dict if needed if use_query_params or use_query_on_post: @@ -329,12 +333,38 @@ def generate_method_body( lines.append(f" {p['name']}={p['name']},") lines.append(" )") + # Build headers dict if needed. Keyed on original_name, never the snake_case + # param name: the wire name is `x-request-id`, and _build_params would have + # camelCased it to `xRequestId`. + if use_headers: + if all(p["required"] for p in header_params): + entries = ", ".join( + f'"{p["original_name"]}": {p["name"]}' for p in header_params + ) + lines.append(f" headers = {{{entries}}}") + else: + lines.append(" headers: dict[str, str] = {}") + for p in header_params: + if p["required"]: + lines.append( + f' headers["{p["original_name"]}"] = {p["name"]}' + ) + else: + lines.append(f" if {p['name']} is not None:") + lines.append( + f' headers["{p["original_name"]}"] = {p["name"]}' + ) + # Make the request if http_method.upper() == "GET": + call_args = [path_expr] if query_params: - lines.append(f" return {await_prefix}self._client.{client_method}({path_expr}, params=params)") - else: - lines.append(f" return {await_prefix}self._client.{client_method}({path_expr})") + call_args.append("params=params") + if use_headers: + call_args.append("headers=headers") + lines.append( + f" return {await_prefix}self._client.{client_method}({', '.join(call_args)})" + ) elif http_method.upper() == "DELETE": if query_params: lines.append(f" return {await_prefix}self._client.{client_method}({path_expr}, params=params)") @@ -348,6 +378,8 @@ def generate_method_body( call_args.append(f"data={raw_body_params[0]['name']}") if query_params: call_args.append("params=params") + if use_headers: + call_args.append("headers=headers") lines.append( f" return {await_prefix}self._client.{client_method}({', '.join(call_args)})" ) diff --git a/src/late/client/base.py b/src/late/client/base.py index 9f83a218..e204c699 100644 --- a/src/late/client/base.py +++ b/src/late/client/base.py @@ -35,6 +35,22 @@ def _resolve_sdk_version() -> str: return "0.0.0+unknown" +def _parse_error_body(response: httpx.Response) -> dict[str, Any]: + """Best-effort parse of an error response body. + + Returns {} when the body is empty, when it isn't valid JSON (e.g. an + HTML 401 from a proxy in front of the API), or when the parsed value + isn't a dict. + """ + if not response.content: + return {} + try: + data = response.json() + except ValueError: + return {} + return data if isinstance(data, dict) else {} + + class BaseClient: """ Base HTTP client supporting both sync and async operations. @@ -115,17 +131,23 @@ def _handle_response(self, response: httpx.Response) -> dict[str, Any]: # Handle errors if response.status_code == 401: - raise LateAuthenticationError("Invalid API key") + error_data = _parse_error_body(response) + raise LateAuthenticationError( + error_data.get("error", "Invalid API key"), details=error_data + ) if response.status_code == 403: - error_data = response.json() if response.content else {} + error_data = _parse_error_body(response) raise LateForbiddenError( - error_data.get("error", "Access forbidden - check your plan") + error_data.get("error", "Access forbidden - check your plan"), + details=error_data, ) if response.status_code == 404: - error_data = response.json() if response.content else {} - raise LateNotFoundError(error_data.get("error", "Resource not found")) + error_data = _parse_error_body(response) + raise LateNotFoundError( + error_data.get("error", "Resource not found"), details=error_data + ) if response.status_code == 429: raise LateRateLimitError( @@ -136,7 +158,7 @@ def _handle_response(self, response: httpx.Response) -> dict[str, Any]: ) if response.status_code >= 400: - error_data = response.json() if response.content else {} + error_data = _parse_error_body(response) # Pass the entire response body through as `details` so callers # (and __str__) can surface the field name (`param`), the stable # error code (`code`), and platform-specific context. The API @@ -146,7 +168,7 @@ def _handle_response(self, response: httpx.Response) -> dict[str, Any]: raise LateAPIError( message=error_data.get("error", f"HTTP {response.status_code}"), status_code=response.status_code, - details=error_data if isinstance(error_data, dict) else None, + details=error_data, ) # Return parsed JSON or empty dict @@ -200,10 +222,13 @@ def _get( self, path: str, params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, ) -> dict[str, Any]: """Make a sync GET request.""" with self._sync_client() as client: - return self._request_with_retry(client, "GET", path, params=params) + return self._request_with_retry( + client, "GET", path, params=params, headers=headers + ) def _post( self, @@ -211,24 +236,27 @@ def _post( data: dict[str, Any] | None = None, files: dict[str, Any] | list[tuple[str, Any]] | None = None, params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, ) -> dict[str, Any]: """Make a sync POST request.""" if files: # For file uploads, create a fresh client without Content-Type # (httpx sets the correct multipart Content-Type automatically) - headers = {k: v for k, v in self._headers.items() if k != "Content-Type"} + client_headers = { + k: v for k, v in self._headers.items() if k != "Content-Type" + } with httpx.Client( base_url=self.base_url, - headers=headers, + headers=client_headers, timeout=self.timeout, ) as client: return self._request_with_retry( - client, "POST", path, files=files, params=params + client, "POST", path, files=files, params=params, headers=headers ) with self._sync_client() as client: return self._request_with_retry( - client, "POST", path, json=data, params=params + client, "POST", path, json=data, params=params, headers=headers ) def _put( @@ -338,10 +366,13 @@ async def _aget( self, path: str, params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, ) -> dict[str, Any]: """Make an async GET request.""" async with self._async_client() as client: - return await self._arequest_with_retry(client, "GET", path, params=params) + return await self._arequest_with_retry( + client, "GET", path, params=params, headers=headers + ) async def _apost( self, @@ -349,23 +380,26 @@ async def _apost( data: dict[str, Any] | None = None, files: dict[str, Any] | list[tuple[str, Any]] | None = None, params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, ) -> dict[str, Any]: """Make an async POST request.""" if files: # For file uploads, create a fresh client without Content-Type - headers = {k: v for k, v in self._headers.items() if k != "Content-Type"} + client_headers = { + k: v for k, v in self._headers.items() if k != "Content-Type" + } async with httpx.AsyncClient( base_url=self.base_url, - headers=headers, + headers=client_headers, timeout=self.timeout, ) as client: return await self._arequest_with_retry( - client, "POST", path, files=files, params=params + client, "POST", path, files=files, params=params, headers=headers ) async with self._async_client() as client: return await self._arequest_with_retry( - client, "POST", path, json=data, params=params + client, "POST", path, json=data, params=params, headers=headers ) async def _aput( diff --git a/src/late/client/exceptions.py b/src/late/client/exceptions.py index 25cc29f2..643b5319 100644 --- a/src/late/client/exceptions.py +++ b/src/late/client/exceptions.py @@ -52,8 +52,12 @@ def __str__(self) -> str: class LateAuthenticationError(LateAPIError): """Exception raised for authentication errors (401).""" - def __init__(self, message: str = "Authentication failed") -> None: - super().__init__(message, status_code=401) + def __init__( + self, + message: str = "Authentication failed", + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, status_code=401, details=details) class LateRateLimitError(LateAPIError): @@ -81,15 +85,23 @@ def __str__(self) -> str: class LateNotFoundError(LateAPIError): """Exception raised when a resource is not found (404).""" - def __init__(self, message: str = "Resource not found") -> None: - super().__init__(message, status_code=404) + def __init__( + self, + message: str = "Resource not found", + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, status_code=404, details=details) class LateForbiddenError(LateAPIError): """Exception raised for forbidden access (403).""" - def __init__(self, message: str = "Access forbidden") -> None: - super().__init__(message, status_code=403) + def __init__( + self, + message: str = "Access forbidden", + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, status_code=403, details=details) class LateValidationError(LateError): diff --git a/src/late/resources/_generated/ad_campaigns.py b/src/late/resources/_generated/ad_campaigns.py index ca4ee417..6cfe77ce 100644 --- a/src/late/resources/_generated/ad_campaigns.py +++ b/src/late/resources/_generated/ad_campaigns.py @@ -214,7 +214,10 @@ def create_ad_campaign( bid_amount=bid_amount, roas_average_floor=roas_average_floor, ) - return self._client._post("/v1/ads/campaigns", data=payload) + headers: dict[str, str] = {} + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key + return self._client._post("/v1/ads/campaigns", data=payload, headers=headers) def update_ad_campaign_status( self, campaign_id: str, status: str, platform: str @@ -297,8 +300,11 @@ def duplicate_ad_campaign( rename_suffix=rename_suffix, sync_after=sync_after, ) + headers: dict[str, str] = {} + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key return self._client._post( - f"/v1/ads/campaigns/{campaign_id}/duplicate", data=payload + f"/v1/ads/campaigns/{campaign_id}/duplicate", data=payload, headers=headers ) def duplicate_ad_set( @@ -330,8 +336,11 @@ def duplicate_ad_set( rename_suffix=rename_suffix, sync_after=sync_after, ) + headers: dict[str, str] = {} + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key return self._client._post( - f"/v1/ads/ad-sets/{ad_set_id}/duplicate", data=payload + f"/v1/ads/ad-sets/{ad_set_id}/duplicate", data=payload, headers=headers ) def duplicate_ad( @@ -355,7 +364,12 @@ def duplicate_ad( rename_suffix=rename_suffix, sync_after=sync_after, ) - return self._client._post(f"/v1/ads/{ad_id}/duplicate", data=payload) + headers: dict[str, str] = {} + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key + return self._client._post( + f"/v1/ads/{ad_id}/duplicate", data=payload, headers=headers + ) def get_ad_set_details( self, ad_set_id: str, account_id: str, *, fields: str | None = None @@ -941,7 +955,12 @@ async def acreate_ad_campaign( bid_amount=bid_amount, roas_average_floor=roas_average_floor, ) - return await self._client._apost("/v1/ads/campaigns", data=payload) + headers: dict[str, str] = {} + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key + return await self._client._apost( + "/v1/ads/campaigns", data=payload, headers=headers + ) async def aupdate_ad_campaign_status( self, campaign_id: str, status: str, platform: str @@ -1026,8 +1045,11 @@ async def aduplicate_ad_campaign( rename_suffix=rename_suffix, sync_after=sync_after, ) + headers: dict[str, str] = {} + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key return await self._client._apost( - f"/v1/ads/campaigns/{campaign_id}/duplicate", data=payload + f"/v1/ads/campaigns/{campaign_id}/duplicate", data=payload, headers=headers ) async def aduplicate_ad_set( @@ -1059,8 +1081,11 @@ async def aduplicate_ad_set( rename_suffix=rename_suffix, sync_after=sync_after, ) + headers: dict[str, str] = {} + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key return await self._client._apost( - f"/v1/ads/ad-sets/{ad_set_id}/duplicate", data=payload + f"/v1/ads/ad-sets/{ad_set_id}/duplicate", data=payload, headers=headers ) async def aduplicate_ad( @@ -1084,7 +1109,12 @@ async def aduplicate_ad( rename_suffix=rename_suffix, sync_after=sync_after, ) - return await self._client._apost(f"/v1/ads/{ad_id}/duplicate", data=payload) + headers: dict[str, str] = {} + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key + return await self._client._apost( + f"/v1/ads/{ad_id}/duplicate", data=payload, headers=headers + ) async def aget_ad_set_details( self, ad_set_id: str, account_id: str, *, fields: str | None = None diff --git a/src/late/resources/_generated/connect.py b/src/late/resources/_generated/connect.py index 9e1295a8..e4c11526 100644 --- a/src/late/resources/_generated/connect.py +++ b/src/late/resources/_generated/connect.py @@ -301,7 +301,10 @@ def list_pinterest_boards_for_selection( profile_id=profile_id, temp_token=temp_token, ) - return self._client._get("/v1/connect/pinterest/select-board", params=params) + headers = {"X-Connect-Token": x_connect_token} + return self._client._get( + "/v1/connect/pinterest/select-board", params=params, headers=headers + ) def select_pinterest_board( self, @@ -336,7 +339,10 @@ def list_snapchat_profiles( profile_id=profile_id, temp_token=temp_token, ) - return self._client._get("/v1/connect/snapchat/select-profile", params=params) + headers = {"X-Connect-Token": x_connect_token} + return self._client._get( + "/v1/connect/snapchat/select-profile", params=params, headers=headers + ) def select_snapchat_profile( self, @@ -360,7 +366,12 @@ def select_snapchat_profile( expires_in=expires_in, redirect_url=redirect_url, ) - return self._client._post("/v1/connect/snapchat/select-profile", data=payload) + headers: dict[str, str] = {} + if x_connect_token is not None: + headers["X-Connect-Token"] = x_connect_token + return self._client._post( + "/v1/connect/snapchat/select-profile", data=payload, headers=headers + ) def connect_bluesky_credentials( self, @@ -423,8 +434,11 @@ def list_whats_app_phone_numbers( profile_id=profile_id, temp_token=temp_token, ) + headers: dict[str, str] = {} + if x_connect_token is not None: + headers["X-Connect-Token"] = x_connect_token return self._client._get( - "/v1/connect/whatsapp/select-phone-number", params=params + "/v1/connect/whatsapp/select-phone-number", params=params, headers=headers ) def complete_whats_app_phone_selection( @@ -447,8 +461,11 @@ def complete_whats_app_phone_selection( user_profile=user_profile, redirect_url=redirect_url, ) + headers: dict[str, str] = {} + if x_connect_token is not None: + headers["X-Connect-Token"] = x_connect_token return self._client._post( - "/v1/connect/whatsapp/select-phone-number", data=payload + "/v1/connect/whatsapp/select-phone-number", data=payload, headers=headers ) def connect_whats_app_embedded_signup( @@ -1002,8 +1019,9 @@ async def alist_pinterest_boards_for_selection( profile_id=profile_id, temp_token=temp_token, ) + headers = {"X-Connect-Token": x_connect_token} return await self._client._aget( - "/v1/connect/pinterest/select-board", params=params + "/v1/connect/pinterest/select-board", params=params, headers=headers ) async def aselect_pinterest_board( @@ -1041,8 +1059,9 @@ async def alist_snapchat_profiles( profile_id=profile_id, temp_token=temp_token, ) + headers = {"X-Connect-Token": x_connect_token} return await self._client._aget( - "/v1/connect/snapchat/select-profile", params=params + "/v1/connect/snapchat/select-profile", params=params, headers=headers ) async def aselect_snapchat_profile( @@ -1067,8 +1086,11 @@ async def aselect_snapchat_profile( expires_in=expires_in, redirect_url=redirect_url, ) + headers: dict[str, str] = {} + if x_connect_token is not None: + headers["X-Connect-Token"] = x_connect_token return await self._client._apost( - "/v1/connect/snapchat/select-profile", data=payload + "/v1/connect/snapchat/select-profile", data=payload, headers=headers ) async def aconnect_bluesky_credentials( @@ -1138,8 +1160,11 @@ async def alist_whats_app_phone_numbers( profile_id=profile_id, temp_token=temp_token, ) + headers: dict[str, str] = {} + if x_connect_token is not None: + headers["X-Connect-Token"] = x_connect_token return await self._client._aget( - "/v1/connect/whatsapp/select-phone-number", params=params + "/v1/connect/whatsapp/select-phone-number", params=params, headers=headers ) async def acomplete_whats_app_phone_selection( @@ -1162,8 +1187,11 @@ async def acomplete_whats_app_phone_selection( user_profile=user_profile, redirect_url=redirect_url, ) + headers: dict[str, str] = {} + if x_connect_token is not None: + headers["X-Connect-Token"] = x_connect_token return await self._client._apost( - "/v1/connect/whatsapp/select-phone-number", data=payload + "/v1/connect/whatsapp/select-phone-number", data=payload, headers=headers ) async def aconnect_whats_app_embedded_signup( diff --git a/src/late/resources/_generated/phone_numbers.py b/src/late/resources/_generated/phone_numbers.py index d7694650..3fdd0a38 100644 --- a/src/late/resources/_generated/phone_numbers.py +++ b/src/late/resources/_generated/phone_numbers.py @@ -209,7 +209,10 @@ def view_phone_number_kyc_document(self, document_id: str) -> dict[str, Any]: def upload_phone_number_kyc_document(self, x_filename: str) -> dict[str, Any]: """Upload a KYC document""" - return self._client._post("/v1/phone-numbers/kyc/upload-document") + headers = {"X-Filename": x_filename} + return self._client._post( + "/v1/phone-numbers/kyc/upload-document", headers=headers + ) def validate_phone_number_kyc_address( self, @@ -543,7 +546,10 @@ async def aupload_phone_number_kyc_document( self, x_filename: str ) -> dict[str, Any]: """Upload a KYC document (async)""" - return await self._client._apost("/v1/phone-numbers/kyc/upload-document") + headers = {"X-Filename": x_filename} + return await self._client._apost( + "/v1/phone-numbers/kyc/upload-document", headers=headers + ) async def avalidate_phone_number_kyc_address( self, diff --git a/src/late/resources/_generated/posts.py b/src/late/resources/_generated/posts.py index 6abd2764..f331ea77 100644 --- a/src/late/resources/_generated/posts.py +++ b/src/late/resources/_generated/posts.py @@ -154,7 +154,10 @@ def create_post( queued_from_profile=queued_from_profile, queue_id=queue_id, ) - return self._client._post("/v1/posts", data=payload) + headers: dict[str, str] = {} + if x_request_id is not None: + headers["x-request-id"] = x_request_id + return self._client._post("/v1/posts", data=payload, headers=headers) def get_post(self, post_id: str) -> dict[str, Any]: """Get post""" @@ -359,7 +362,10 @@ async def acreate_post( queued_from_profile=queued_from_profile, queue_id=queue_id, ) - return await self._client._apost("/v1/posts", data=payload) + headers: dict[str, str] = {} + if x_request_id is not None: + headers["x-request-id"] = x_request_id + return await self._client._apost("/v1/posts", data=payload, headers=headers) async def aget_post(self, post_id: str) -> dict[str, Any]: """Get post (async)""" diff --git a/src/late/resources/_generated/whatsapp_phone_numbers.py b/src/late/resources/_generated/whatsapp_phone_numbers.py index c3d32894..10795c8d 100644 --- a/src/late/resources/_generated/whatsapp_phone_numbers.py +++ b/src/late/resources/_generated/whatsapp_phone_numbers.py @@ -192,7 +192,10 @@ def submit_whats_app_number_kyc( def upload_whats_app_number_kyc_document(self, x_filename: str) -> dict[str, Any]: """Upload a KYC document""" - return self._client._post("/v1/whatsapp/phone-numbers/kyc/upload-document") + headers = {"X-Filename": x_filename} + return self._client._post( + "/v1/whatsapp/phone-numbers/kyc/upload-document", headers=headers + ) def validate_whats_app_number_kyc_address( self, @@ -402,8 +405,9 @@ async def aupload_whats_app_number_kyc_document( self, x_filename: str ) -> dict[str, Any]: """Upload a KYC document (async)""" + headers = {"X-Filename": x_filename} return await self._client._apost( - "/v1/whatsapp/phone-numbers/kyc/upload-document" + "/v1/whatsapp/phone-numbers/kyc/upload-document", headers=headers ) async def avalidate_whats_app_number_kyc_address(