Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions scripts/generate_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"'
Expand All @@ -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:
Expand All @@ -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)")
Expand All @@ -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)})"
)
Expand Down
68 changes: 51 additions & 17 deletions src/late/client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -200,35 +222,41 @@ 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,
path: str,
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(
Expand Down Expand Up @@ -338,34 +366,40 @@ 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,
path: str,
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(
Expand Down
24 changes: 18 additions & 6 deletions src/late/client/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
46 changes: 38 additions & 8 deletions src/late/resources/_generated/ad_campaigns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
Loading
Loading