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
44 changes: 32 additions & 12 deletions solutions/ess-maker-skills/scripts/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
print("ERROR: 'urllib3' / 'requests' not found. Run: pip install requests")
sys.exit(1)

from http_errors import APIError, raise_api_error # noqa: E402


# Microsoft public client ID for Power Platform CLI / Dataverse delegated access.
# Source: https://learn.microsoft.com/power-platform/admin/programmability-authentication-v2
Expand All @@ -59,9 +61,27 @@
}


class AuthExpiredError(RuntimeError):
class AuthExpiredError(APIError):
"""Raised when a Dataverse call returns 401. Callers can catch this and
re-authenticate without losing in-flight push state."""
re-authenticate without losing in-flight push state.

Subclasses APIError so generic ``except APIError`` handlers (e.g. in
discover.py, fetch_and_setup.py) also catch 401s and render the friendly
"session expired" message. Code that wants to specifically intercept 401
for re-auth (push.py) keeps using ``except AuthExpiredError``.
"""

def __init__(self, message=None, response=None):
# Preserve the legacy positional ``message`` argument for callers and
# tests that construct AuthExpiredError("...") directly. When a real
# Response is available, pass it through so URL / method / request_id
# show up in format_for_terminal() output.
super().__init__(
response=response,
status_code=401,
operation="access",
message=message,
)


# Module-level requests Session with bounded retry-with-backoff for 429/5xx.
Expand Down Expand Up @@ -202,8 +222,8 @@ def query_all(env_url, token, entity_set, select, filter_expr=None):
page += 1
resp = _SESSION.get(url, headers=headers, timeout=120, verify=True)
if resp.status_code == 401:
raise AuthExpiredError("Dataverse returned 401 (token expired or invalid)")
resp.raise_for_status()
raise AuthExpiredError(response=resp)
raise_api_error(resp, resource_name=entity_set, operation="read")
Comment thread
amilandi marked this conversation as resolved.
data = resp.json()
records = data.get("value", [])
all_records.extend(records)
Expand Down Expand Up @@ -246,7 +266,7 @@ def retrieve_shared_principals_and_access(env_url, token, bot_id):
headers = {**HEADERS_BASE, "Authorization": f"Bearer {token}"}
resp = _SESSION.get(url, headers=headers, timeout=120, verify=True)
if resp.status_code == 401:
raise AuthExpiredError("Dataverse returned 401 (token expired or invalid)")
raise AuthExpiredError(response=resp)
resp.raise_for_status()
return resp.json()

Expand Down Expand Up @@ -301,7 +321,7 @@ def dataverse_get(env_url, token, path, params=None):
url = f"{env_url}/api/data/v9.2/{path.lstrip('/')}"
resp = _SESSION.get(url, headers=headers, params=params, timeout=60, verify=True)
if resp.status_code == 401:
raise AuthExpiredError("Dataverse returned 401 (token expired or invalid)")
raise AuthExpiredError(response=resp)
resp.raise_for_status()
return resp.json()

Expand All @@ -323,8 +343,8 @@ def update_record(env_url, token, entity_set, record_id, data):
url = f"{env_url}/api/data/v9.2/{entity_set}({record_id})"
resp = _SESSION.patch(url, headers=headers, json=data, timeout=60, verify=True)
if resp.status_code == 401:
raise AuthExpiredError("Dataverse returned 401 (token expired or invalid)")
resp.raise_for_status()
raise AuthExpiredError(response=resp)
raise_api_error(resp, resource_name=entity_set, operation="update")
return True


Expand All @@ -340,8 +360,8 @@ def create_record(env_url, token, entity_set, data):
url = f"{env_url}/api/data/v9.2/{entity_set}"
resp = _SESSION.post(url, headers=headers, json=data, timeout=60, verify=True)
if resp.status_code == 401:
raise AuthExpiredError("Dataverse returned 401 (token expired or invalid)")
resp.raise_for_status()
raise AuthExpiredError(response=resp)
raise_api_error(resp, resource_name=entity_set, operation="create")
result = resp.json()
return result.get("botcomponentid", result.get("id"))

Expand All @@ -356,8 +376,8 @@ def delete_record(env_url, token, entity_set, record_id):
url = f"{env_url}/api/data/v9.2/{entity_set}({record_id})"
resp = _SESSION.delete(url, headers=headers, timeout=60, verify=True)
if resp.status_code == 401:
raise AuthExpiredError("Dataverse returned 401 (token expired or invalid)")
resp.raise_for_status()
raise AuthExpiredError(response=resp)
raise_api_error(resp, resource_name=entity_set, operation="delete")
return True


Expand Down
7 changes: 6 additions & 1 deletion solutions/ess-maker-skills/scripts/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
# Add scripts/ to path so we can import auth
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from auth import authenticate, query_all
from http_errors import APIError


def discover_agents(env_url, token):
Expand Down Expand Up @@ -124,7 +125,11 @@ def main():
print("Authenticated.\n")

print("Discovering agents...")
agents = discover_agents(env_url, token)
try:
agents = discover_agents(env_url, token)
except APIError as e:
print(e.format_for_terminal())
sys.exit(1)

if not agents:
print("No agents found in this environment.")
Expand Down
22 changes: 15 additions & 7 deletions solutions/ess-maker-skills/scripts/fetch_and_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
# Add scripts/ to path so we can import auth
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from auth import authenticate, load_config, query_all
from http_errors import APIError


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -122,7 +123,6 @@ def fetch_all(env_url, token, bot_id, components=None):

Returns (components, template_configs, workflows).
"""
import requests as _requests # local import to avoid top-level dep

# --- Fetch components ---
if components is None:
Expand Down Expand Up @@ -151,8 +151,8 @@ def fetch_all(env_url, token, bot_id, components=None):
)
template_configs = normalize_template_configs(raw_configs)
print(f" {len(template_configs)} template configs fetched.\n")
except _requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code == 404:
except APIError as e:
if e.status_code == 404:
print(" Table not found (ESS template configs not deployed). "
"Skipping.\n")
else:
Expand Down Expand Up @@ -282,8 +282,12 @@ def main():
token = authenticate(env_url)
print("Authenticated.\n")

components, template_configs, workflows = fetch_all(
env_url, token, bot_id)
try:
components, template_configs, workflows = fetch_all(
env_url, token, bot_id)
except APIError as e:
print(e.format_for_terminal())
sys.exit(1)
paths = save_temp_files(components, template_configs, workflows)
rc = run_setup(env_url, bot_id, name, schema, managed,
paths, extra_flags=["--refresh"])
Expand All @@ -301,8 +305,12 @@ def main():
token = authenticate(env_url)
print("Authenticated.\n")

components, template_configs, workflows = fetch_all(
env_url, token, args.bot_id)
try:
components, template_configs, workflows = fetch_all(
env_url, token, args.bot_id)
except APIError as e:
print(e.format_for_terminal())
sys.exit(1)
paths = save_temp_files(components, template_configs, workflows)
rc = run_setup(env_url, args.bot_id, args.name, args.schema,
args.managed, paths)
Expand Down
195 changes: 195 additions & 0 deletions solutions/ess-maker-skills/scripts/http_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

"""
ESS Maker Kit - User-Friendly HTTP Error Handling

Provides a central helper that translates raw HTTP status codes into
actionable messages with troubleshooting tips. Used by auth.py and
the installer scripts (discover.py, fetch_and_setup.py) to surface
clear guidance when API calls fail.

FlightCheck clients (graph_client, pp_admin_client, pva_client) have
their own error patterns (returning _error dicts) and are NOT changed.
"""

import requests


# Friendly display names for Dataverse entity sets.
_ENTITY_DISPLAY_NAMES = {
"bots": "agents",
"botcomponents": "agent components",
"workflows": "cloud flows",
"msdyn_employeeselfservicetemplateconfigs": "ESS template configurations",
"connections": "connections",
"connectionreferences": "connection references",
}


def _friendly_name(resource_name):
Comment thread
amilandi marked this conversation as resolved.
"""Map an entity set name to a user-friendly label."""
if not resource_name:
return "this resource"
return _ENTITY_DISPLAY_NAMES.get(resource_name, resource_name)


class APIError(requests.exceptions.HTTPError):
"""HTTP error with user-friendly message and troubleshooting tip.

Subclasses requests.HTTPError so existing `except HTTPError` handlers
still catch it. Adds structured fields for display formatting.
"""

def __init__(self, response=None, resource_name=None, operation=None,
required_role=None, message=None, tip=None, status_code=None):
# When a response is provided (the common case), pull diagnostics
# from it. When it isn't (e.g. a synthetic AuthExpiredError raised
# by something that doesn't have a Response on hand), the caller
# must supply status_code so the friendly message picks the right
# template.
if response is not None:
self.status_code = response.status_code
self.url = (response.request.url if response.request else None)
self.request_id = response.headers.get("x-ms-request-id")
self._method = (
response.request.method if response.request else None
)
else:
self.status_code = status_code or 0
self.url = None
self.request_id = None
self._method = None

self.resource_name = resource_name
self.operation = operation or "access"
self.required_role = required_role

# Build user-facing message
friendly = _friendly_name(resource_name)
self._friendly_message = message or _build_message(
self.status_code, friendly, self.operation, self.required_role
)
self._tip = tip or _build_tip(
self.status_code, friendly, self.operation, self.required_role
)

# Call parent with the friendly message as the exception string
super().__init__(str(self), response=response)

def __str__(self):
parts = [self._friendly_message]
if self._tip:
parts.append(f" -> {self._tip}")
return "\n".join(parts)

def format_for_terminal(self):
"""Format the error for terminal display with technical details."""
lines = [
"",
f" ERROR: {self._friendly_message}",
]
if self._tip:
lines.append(f" Tip: {self._tip}")
# Technical detail line (compact, for debugging)
if self.url:
# Truncate URL to avoid leaking full query strings
display_url = self.url.split("?")[0]
method_verb = (self._method or "GET").upper()
lines.append(
f" Detail: HTTP {self.status_code} — {method_verb} {display_url}"
)
if self.request_id:
lines.append(f" Request ID: {self.request_id}")
lines.append("")
return "\n".join(lines)


def _build_message(status_code, friendly_resource, operation, required_role):
"""Build the main error message based on status code."""
if status_code == 400:
return (
f"Bad request while trying to {operation} {friendly_resource}. "
"The API rejected the query — this may indicate a version mismatch "
"or unsupported filter."
)
if status_code == 401:
return (
"Your session has expired or the token is invalid (HTTP 401)."
)
if status_code == 403:
return (
f"You signed in successfully, but your account doesn't have "
f"permission to {operation} {friendly_resource}."
)
if status_code == 404:
return (
f"Could not find {friendly_resource}. The resource doesn't exist "
"or the required solution may not be deployed in this environment."
)
if status_code == 429:
return (
"Too many requests — the API is rate-limiting your account."
)
if status_code in (500, 502, 503, 504):
return (
f"The server returned an error (HTTP {status_code}) while trying "
f"to {operation} {friendly_resource}. This is usually temporary."
)
return (
f"Unexpected HTTP {status_code} while trying to {operation} "
f"{friendly_resource}."
)


def _build_tip(status_code, friendly_resource, operation, required_role):
"""Build the troubleshooting tip for a given status code."""
if status_code == 400:
return (
"Check that the environment URL is correct and the ESS solution "
"version matches this kit version."
)
if status_code == 401:
return "Run the command again — you'll be prompted to sign in."
if status_code == 403:
role = required_role or "Bot Author, Bot Contributor, or System Administrator"
return (
f"Ask your Power Platform admin to assign the {role} security role "
"to your account in this environment."
)
if status_code == 404:
return (
"Verify the ESS solution is installed in this environment, or "
"choose a different environment."
)
if status_code == 429:
return "Wait 1-2 minutes and try again."
if status_code in (500, 502, 503, 504):
return (
"Wait a few minutes and try again. If the problem persists, check "
"the Power Platform Service Health dashboard."
)
return "Check the status code and URL above for clues."


def raise_api_error(response, resource_name=None, operation=None,
required_role=None):
"""Inspect a response and raise APIError if it indicates failure.

Call this AFTER any 401 check (AuthExpiredError) but INSTEAD of
resp.raise_for_status(). This produces user-friendly errors for
all non-2xx responses.

Args:
response: The requests.Response object.
resource_name: The Dataverse entity set or API resource being accessed.
operation: The operation being performed (read/create/update/delete).
required_role: Role to suggest in 403 messages (optional).
"""
if response.status_code >= 400:
raise APIError(
response,
resource_name=resource_name,
operation=operation,
required_role=required_role,
)
Loading
Loading