///|
/// The error body returned by the GitHub REST API.
///
/// `status` is a string because that is how GitHub writes it (`"404"`); a
/// numeric value is accepted and rendered the same way. `errors` keeps the raw
/// elements: a `422` reports objects with `resource`, `field`, and `code`,
/// while a few endpoints report plain strings.
///
/// ```mbt check
/// test {
/// let body : @github.ApiErrorBody = {
/// message: "Not Found",
/// documentation_url: Some("https://docs.github.com/rest"),
/// status: Some("404"),
/// errors: None,
/// }
/// assert_eq(body.message, "Not Found")
/// }
/// ```
pub(all) struct ApiErrorBody {
message : String
documentation_url : String?
status : String?
errors : Array[Json]?
} derive(Eq, @debug.Debug)
///|
/// Compares API error bodies field by field.
pub extend ApiErrorBody with Eq::{equal, not_equal}
///|
/// Debug representation of an API error body.
pub extend ApiErrorBody with @debug.Debug::{to_repr}
///|
/// Extracts GitHub's error body from an HTTP status failure.
///
/// Returns None for transport, decode, and configuration failures, and for a
/// body that is not a GitHub error object — an HTML page from an intermediate
/// proxy, or an empty body.
pub fn api_error(error : @runtime.SdkError) -> ApiErrorBody? {
let body = match error {
@runtime.Status(body~, ..) | @runtime.RateLimited(body~, ..) => body
_ => return None
}
try decode_api_error_body(body) catch {
_ => None
} noraise {
value => Some(value)
}
}
///|
/// Reports the `304` answer to a conditional request.
///
/// A request carrying `if-none-match` or `if-modified-since` is answered with
/// `304 Not Modified` and an empty body, which the runtime classifies as a
/// failure like any other non-2xx status. A cached copy is still valid, and a
/// `304` is not charged against the rate limit.
pub fn is_not_modified(error : @runtime.SdkError) -> Bool {
error.status() is Some(304)
}
///|
/// Reports a secondary rate limit, which is not the documented hourly quota.
///
/// GitHub answers the hourly quota with `403` or `429` and
/// `x-ratelimit-remaining: 0`; `WindowLimiter` paces against those headers
/// and `GitHubRetry` waits for the reset once. A secondary rate limit — too
/// many concurrent requests, too many points in a minute, too much content
/// creation — is a separate mechanism, reported with `403` or `429` plus a
/// `retry-after` header, or a message naming it (matched as octokit does,
/// "secondary rate" in any case), in a REST error body or in the `errors` of
/// a GraphQL reply. It leaves `x-ratelimit-remaining` untouched, so no limiter
/// can anticipate it: `GitHubRetry` waits and retries after it occurs.
///
/// The runtime's taxonomy classifies only `429` as `RateLimited`, so a
/// secondary limit delivered as `403` arrives as `Status`; one delivered in a
/// GraphQL `200` is raised by `graphql` as `RateLimited`. This predicate spans
/// all three.
pub fn is_secondary_rate_limit(error : @runtime.SdkError) -> Bool {
let (status, headers, body) = match error {
@runtime.Status(status~, headers~, body~) => (status, headers, body)
@runtime.RateLimited(headers~, body~, ..) => (429, headers, body)
_ => return false
}
guard status == 403 || status == 429 else { return false }
headers.contains("retry-after") || mentions_secondary_rate_limit(body)
}
///|
/// Reports whether a REST error body or a GraphQL reply names the secondary
/// rate limit.
fn mentions_secondary_rate_limit(body : Bytes) -> Bool {
let rest = try decode_api_error_body(body) catch {
_ => false
} noraise {
value => names_secondary_rate_limit(value.message)
}
rest || graphql_reply_has(body, GraphqlSecondary)
}
///|
/// Whether a message names the secondary rate limit, as octokit's
/// `/\bsecondary rate\b/i` does.
fn names_secondary_rate_limit(message : String) -> Bool {
message.to_lower().contains("secondary rate")
}
///|
/// Decodes a GitHub error body, raising when it is not one.
fn decode_api_error_body(body : Bytes) -> ApiErrorBody raise {
let value = @json.parse(@utf8.decode(body))
let decoded : ApiErrorJson = @json.from_json(value)
decoded.0
}
///|
priv struct ApiErrorJson(ApiErrorBody)
///|
impl @json.FromJson for ApiErrorJson with fn from_json(value, path) {
let obj = @sdkjson.expect_object(value, path)
ApiErrorJson({
message: @sdkjson.field(obj, "message", path),
documentation_url: @sdkjson.opt_field(obj, "documentation_url", path),
status: decode_status(obj, path),
errors: @sdkjson.opt_field(obj, "errors", path),
})
}
///|
/// Reads `status`, which GitHub writes as a string but neighbouring services
/// sometimes write as a number.
fn decode_status(
obj : Map[String, Json],
path : @json.JsonPath,
) -> String? raise @json.JsonDecodeError {
match obj.get("status") {
None | Some(Null) => None
Some(String(value)) => Some(value)
Some(Number(value, ..)) => Some(value.to_int().to_string())
Some(_) =>
raise @json.JsonDecodeError(
(path.add_key("status"), "expected a string or number"),
)
}
}