The authenticated request helper shared by the microsoft-* packages, plus Graph-shaped errors, retry classification, OData query building, @odata.nextLink paging, and JSON batching.

Paths. path is either a Graph path ("/me/drive", sent to {graph}/v1.0/me/drive for the call's cloud; opts.api-version: "beta" switches version) or a full https:// URL such as an @odata.nextLink. A full URL gets credentials only on the call's Graph root or a Microsoft Graph cloud host (see may-receive-credentials); anything else is an Err, and nothing is sent or minted.

Retry. microsoft-graph never waits between attempts. A connection failure on an idempotent method (GET, HEAD, PUT, DELETE, OPTIONS) is retried once, immediately (opts.retry: false turns that off). Everything else, including 429 and 503 throttling, comes back as err(HttpError) with retry-after set from the Retry-After header. Check it with is-retryable and let Hot back off: give the handler, schedule or task a retry policy and fail the run (see https://hot.dev/docs/retries).

401 handling. When a cached token is rejected with 401, it is evicted and the call is retried once with a freshly minted token.

Request ids. Every call sends a fresh client-request-id (or opts.client-request-id; false sends none) and asks Graph to echo it. HttpError carries it with Graph's request-id, which is what Microsoft support asks for.

Values

IDEMPOTENT_METHODS

IDEMPOTENT_METHODS: Vec [
  "GET",
  "HEAD",
  "PUT",
  "DELETE",
  "OPTIONS"
]

MAX_BATCH_SIZE

MAX_BATCH_SIZE: Int 20

MAX_IMMEDIATE_RETRIES

MAX_IMMEDIATE_RETRIES: Int 1

MAX_PAGES

MAX_PAGES: Int 10000

ODATA_OPTIONS

ODATA_OPTIONS: Map {
  select: "$select",
  expand: "$expand",
  filter: "$filter",
  orderby: "$orderby",
  order-by: "$orderby",
  top: "$top",
  skip: "$skip",
  search: "$search",
  count: "$count",
  skiptoken: "$skiptoken"
}

RETRY_STATUSES

RETRY_STATUSES: Vec [
  408,
  429,
  500,
  502,
  503,
  504
]

Functions

api-params

fn (opts: Map): Map

Strip microsoft-graph's own keys (::microsoft::graph::auth/CREDENTIAL_KEYS: credentials, cloud, scopes, retry, headers, ...) from an opts Map, leaving only API parameters.

batch

fn (entries: Vec): Map
fn (entries: Vec, opts: Map): Map

POST /$batch - send up to 20 requests in one call. Each entry is {id?, method, url, headers?, body?, depends-on?} with url a Graph path ("/me/drive/root/children"). Returns {responses, by-id, failed, retryable} (see order-responses); the batch itself fails only when the whole request does. Individual 429s are listed in retryable: resend just those later. Use batch-chunks for more than 20.

out ::microsoft::graph::api/batch([
  {id: "me", method: "GET", url: "/me?$select=id,displayName"},
  {id: "drive", method: "GET", url: "/me/drive?$select=id,quota"}
])
get(out.by-id, "drive").body.quota

batch-body

fn (body: Any): Any

batch-chunks

fn (entries: Vec): Vec

Split entries into groups of at most 20 for batch (pure).

body-excerpt

fn (body: Any): Str

A short (at most 300 characters) printable form of a response body, for error messages.

build-batch

fn (entries: Vec): Map

Build a $batch body {requests: [...]} from entries (pure); an Err for zero or more than 20 entries or duplicate ids.

build-batch-request

fn (entry: Map, index: Int): Map

Normalise one batch entry (pure): {id?, method, url, headers?, body?, depends-on?}. id defaults to the 1-based position; url is made relative; a Map/Vec body gets Content-Type: application/json; a Bytes body is base64-encoded (Graph's form for binary bodies); depends-on (Vec of ids) becomes dependsOn.

build-http-request

fn (method: Str, url: Str, headers: Map, body: Any, token: Str?, request-id: Str?): ::hot::http/HttpRequest

Build the outgoing HttpRequest (pure). token (nullable) becomes the Bearer header; request-id the client-request-id header with return-client-request-id: true. Map/Vec bodies are JSON-encoded with a JSON Content-Type; Str/Bytes bodies are sent verbatim (set Content-Type in headers); "" or null sends no body. Caller headers win.

client-request-id

fn (opts: Map): Str?

The client-request-id to send (pure apart from the UUID): opts.client-request-id when a Str, none when it is false, else a fresh UUID.

collect-loop

fn (fetch: Fn, limit: Int?, next-link: Str?, acc: Vec, pages: Int): Vec

collect-pages

fn (path: Str, opts: Map): Vec
fn (path: Str, opts: Map, limit: Int?): Vec

Follow @odata.nextLink and collect every item of path, or at most limit items (null for no limit). opts holds OData parameters and credentials as for fetch-page. The first failing page's Err is returned.

users ::microsoft::graph::api/collect-pages("/users", {select: ["id", "mail"], top: 999}, 5000)

collect-with

fn (fetch: Fn, limit: Int?): Vec

Collect items with your own page function: fetch is (next-link: Str?) -> Page (null for the first page). Stops at limit items (null for all).

credential-opts

fn (opts: Map): Map

Keep only microsoft-graph's own keys (credentials, cloud, scopes, retry, headers, ...) from an opts Map, e.g. to pass them on to a follow-up call.

decode-error-body

fn (body: Any): Any

encode-query-key

fn (key: Str): Str

encode-segment

fn (value: Str): Str

URL-encode one path segment (an id, a user principal name, a site id with commas, ...).

fetch-page

fn (path: Str, opts: Map, next-link: Str?): Page

Fetch one page: GET next-link when given, else path with the OData parameters in opts. The query's ConsistencyLevel header is sent on every page (Graph does not carry it over).

page ::microsoft::graph::api/fetch-page("/users", {select: "id,displayName", top: 100}, null)
page.items
next ::microsoft::graph::api/fetch-page("/users", {select: "id,displayName", top: 100}, page.next-link)

get-json

fn (path: Str, opts: Map): Any

GET a Graph path with OData query parameters taken from opts (see odata-params; credential keys are left for auth), sending ConsistencyLevel: eventual when $search / $count need it.

users ::microsoft::graph::api/get-json("/users", {select: ["id", "displayName"], search: "displayName:alex", count: true})

graph-error-details

fn (body: Any): Map

Extract Graph's error fields from a response body (pure): {code, message, inner-code, request-id, client-request-id}. Handles the standard {error: {code, message, innerError: {request-id, client-request-id, code?}}} shape (also innererror), and the OAuth-style {error: "invalid_token", error_description} shape. Fields are null when absent.

has-code

fn (error: Result, code: Str): Bool

True when error (an HttpError, a Result.Err holding one, or any error payload) carries the Graph error code (or inner code).

header-value

fn (headers: Any, name: Str): Str?

A response header by lower-case name, or null when absent or empty.

innermost-code

fn (inner: Any, found: Str?): Str?

is-connection-failure

fn (message: Str): Bool

True when a transport error message is a connection or read failure (worth trying again), not a size-limit or URL error.

is-full-url

fn (path: Str): Bool

is-retryable

fn (error: Result): Bool

True when a failed call is worth retrying later: an HttpError (or a Result.Err holding one, or a raw HttpResponse) with status 408, 429, 500, 502, 503 or 504; a TokenError from the token endpoint with one of those statuses; or a connection failure. False for successes and for permanent errors (400, 401, 403, 404, 409, 410, ...).

microsoft-graph does not wait and retry for you: Hot has no in-run sleep. Give the handler, schedule or task a retry policy (see https://hot.dev/docs/retries) and fail the run when this returns true; retry-after on the HttpError says how long Graph asked you to wait.

sync-files
meta {
  schedule: "every 15 minutes",
  retry: {attempts: 5, delay: 5000, backoff: "exponential", max_delay: 120000, jitter: true}
}
fn (event) {
  me ::microsoft::graph::api/json("GET", "/me/drive", {})
  cond {
    ::microsoft::graph::api/is-retryable(me) => { fail(`graph: retrying later: ${err-message(me)}`) }
    is-err(me) => { println(`graph: giving up: ${err-message(me)}`) }
    => { me.id }
  }
}

is-retryable-status

fn (status: Int): Bool

True for statuses Graph asks clients to retry later: 408, 429 (throttled), 500, 502, 503 (service unavailable, often with Retry-After) and 504.

is-retryable-transport

fn (method: Str, message: Str): Bool

True when a transport failure may be retried immediately: an idempotent method (GET, HEAD, PUT, DELETE, OPTIONS) and a connection failure.

json

fn (method: Str, path: Str): Any
fn (method: Str, path: Str, opts: Map): Any
fn (method: Str, path: Str, headers: Map, body: Any, opts: Map): Any

Like request, but unwraps: the decoded JSON body on 2xx ({} for 204 No Content), or an Err carrying HttpError (credential failures carry ::microsoft::graph::auth/TokenError). Takes the same arities as request.

may-receive-credentials

fn (url: Str, graph: Str): Bool

True when a full URL may receive credentials (pure): https on the host of graph (the call's resolved Graph root) or of a Microsoft Graph cloud (::microsoft::graph/CLOUDS). http only on the host of a graph root that is itself http, for tests and local proxies. Hosts match exactly, so graph.microsoft.com.evil.example is refused.

::microsoft::graph::api/may-receive-credentials("https://graph.microsoft.us/v1.0/me", "https://graph.microsoft.com")  // true
::microsoft::graph::api/may-receive-credentials("https://evil.example/v1.0/me", "https://graph.microsoft.com")  // false

needs-eventual-consistency

fn (params: Map): Bool

True when query parameters use $search or $count, which Graph's advanced directory queries only accept with ConsistencyLevel: eventual (pure).

odata-key

fn (key: Str): Str

The query key for an option name: select becomes $select (see ODATA_OPTIONS); keys already starting with $ and any other names pass through.

odata-params

fn (params: Map): Map

Normalise query parameters (pure): microsoft-graph keys are removed, nulls dropped, OData short names (select, expand, filter, orderby, top, skip, search, count) become their $ keys, Vec values are comma-joined (select: ["id", "name"]), Bools become true/false, and a $search value is double-quoted.

::microsoft::graph::api/odata-params({select: ["id", "displayName"], top: 50, filter: null})
// {"$select": "id,displayName", "$top": "50"}

odata-value

fn (key: Str, value: Any): Str

order-responses

fn (requests: Vec, reply: Map): Map

Parse a $batch reply (pure): {responses: Vec<BatchResponse>} in the order of requests (Graph answers in any order), plus by-id, the ids that failed, and those that are retryable.

parse-batch-response

fn (r: Map): BatchResponse

Read one entry of a $batch reply's responses into a BatchResponse (pure).

plain-payload

fn (value: Result): Any

The payload of a Result.Err (or the value itself), with type tags removed so an HttpError / TokenError reads like a Map.

query-headers

fn (params: Map): Map

The extra headers a query needs (pure): {ConsistencyLevel: "eventual"} when needs-eventual-consistency, else {}. Send them on every page of a paged query.

query-string

fn (params: Map): Str

Encode normalised parameters as a query string (pure). Values are percent-encoded per RFC 3986 (spaces as %20, never +, which some Graph services read literally); the $ of OData keys stays literal. Keys are sorted, so output is stable.

fn (value: Str): Str

Wrap a $search value in double quotes unless it already is (Graph requires $search="..." for directory objects and messages). Embedded " are escaped as \\".

refused-url-error

fn (url: Str): Any

relative-url

fn (url: Str): Str

The batch form of a URL (pure): the path after /v1.0 (or /beta) for a full Graph URL, with a leading /; relative paths pass through.

request

fn (method: Str, path: Str): ::hot::http/HttpResponse
fn (method: Str, path: Str, opts: Map): ::hot::http/HttpResponse
fn (method: Str, path: Str, headers: Map, body: Any, opts: Map): ::hot::http/HttpResponse

Make an authenticated request to Microsoft Graph and return the raw HttpResponse (non-2xx included), or an Err for credential and transport failures.

  • method - "GET", "POST", "PATCH", "PUT", "DELETE"
  • path - a Graph path ("/me/drive/root/children") or a full URL on the call's Graph root or a Microsoft Graph cloud host; any other host is an Err, with no credentials sent or minted
  • headers - extra headers (they win over the defaults and opts.headers)
  • body - a Map/Vec (sent as JSON), Str/Bytes (sent verbatim), or ""
  • opts - per-call credentials (see ::microsoft::graph::auth), headers, api-version, client-request-id, and retry (false disables the one immediate retry of a failed connection)
response ::microsoft::graph::api/request("GET", "/me", {})

// As one tenant user, without touching context
response2 ::microsoft::graph::api/request("GET", "/me/drive", {}, "", {token: "eyJ0eXAi..."})

resolve

fn (): Map
fn (opts: Map): Map

The package's one credential and settings resolver: per-call opts first, then context, then defaults (see ::microsoft::graph::auth). Returns resolved credentials {kind, cloud, ...} or a TokenError: a credential key in opts that is blank or null, and graph-url / login-url in opts without a credential, are errors, never a fallback to context. Credentials from opts are marked as secrets (masked in call records) and never written to context.

resolve-url

fn (path: Str, graph: Str, opts: Map): Str

The absolute URL for path (pure): full http(s):// URLs pass through; a Graph path is joined to {graph}/{version} (v1.0 unless opts.api-version is set). A missing leading / is added.

retry-after-seconds

fn (headers: Map?): Int?

The Retry-After header of a response's headers as whole seconds (Int), or null when absent or given as an HTTP date.

retry-policy

fn (opts: Map): Map

The immediate-retry policy for a call (pure): {max-retries: 1}, or {max-retries: 0} when opts.retry is false or 0. Values are clamped to 0..1: microsoft-graph never waits between attempts, so it never retries more than once.

send-with-retry

fn (req: ::hot::http/HttpRequest, policy: Map): ::hot::http/HttpResponse
fn (req: ::hot::http/HttpRequest, policy: Map, attempt: Int): ::hot::http/HttpResponse

Send an HttpRequest; on a connection failure of an idempotent method, send it once more straight away (per policy). Never waits. Returns the final response or transport Err.

to-page

fn (response: Map): Page

Read a decoded collection response into a Page (pure).

unwrap

fn (response: Result): Any

The decoded body of a 2xx response ({} for an empty 204), the Err of a failed request, or err(HttpError) for a non-2xx response.

url-parts

fn (url: Str): Map?

The lower-cased {scheme, host} of an http(s) URL (pure), or null unless the authority is plainly a host and optional port. Userinfo, backslashes, extra slashes and control characters all give null: URL parsers disagree on where the host is in those, and a backslash ends the authority in the one that opens the connection, so https://evil.example\@graph.microsoft.com/ connects to evil.example.

with-query

fn (url: Str, params: Map): Str

Append query parameters to a URL or path (see odata-params and query-string), dropping nulls. Works whether or not the URL already has a query.

::microsoft::graph::api/with-query("/users", {select: "id,mail", filter: "startswith(displayName,'A')", top: 10})
// "/users?$filter=startswith%28displayName%2C%27A%27%29&$select=id%2Cmail&$top=10"

Types

BatchResponse

BatchResponse type {
    id: Str,
    status: Int,
    headers: Map,
    body: Any,
    ok: Bool,
    error: HttpError?,
    retry-after: Int?
}

One response inside a $batch reply.

  • id, status, headers, body (decoded JSON, or the raw value)
  • ok - true for 2xx
  • error - an HttpError for non-2xx, else null
  • retry-after - from the individual response's Retry-After

HttpError

HttpError type {
    status: Int,
    headers: Map?,
    body: Any,
    message: Str?,
    code: Str?,
    inner-code: Str?,
    error-message: Str?,
    request-id: Str?,
    client-request-id: Str?,
    retry-after: Int?
}

A non-2xx response from Microsoft Graph.

Fields

  • status - HTTP status code
  • headers, body - the raw response (body is decoded JSON when JSON)
  • message - readable summary, e.g. "microsoft-graph: HTTP 404 itemNotFound: The resource could not be found."
  • code - Graph's error.code (e.g. "itemNotFound", "accessDenied", "activityLimitReached", "resyncRequired")
  • inner-code - the innermost innerError.code, when present
  • error-message - Graph's error.message
  • request-id, client-request-id - from innerError or the response headers, for Microsoft support
  • retry-after - seconds from a numeric Retry-After header, else null

Page

Page type {
    items: Vec,
    next-link: Str?,
    delta-link: Str?,
    count: Int?,
    response: Map
}

One page of a collection: items (value), next-link (@odata.nextLink, null on the last page), delta-link (@odata.deltaLink, set on the last page of a delta query), count (@odata.count when requested), and the decoded response.