{"record":{"id":"36e214088ac7f6f5","repo":"gitbutlerapp/gitbutler","slug":"github-graphql-request-failed","errorCode":null,"errorMessage":"GitHub GraphQL request failed: {}","messagePattern":"GitHub GraphQL request failed: (.+?)","errorType":"http","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/but-github/src/client.rs","lineNumber":1264,"sourceCode":"        V: Serialize,\n    {\n        #[derive(Serialize)]\n        struct GraphQlRequest<'a, V> {\n            query: &'a str,\n            variables: &'a V,\n        }\n\n        let url = graphql_endpoint_from_base_url(&self.base_url);\n\n        let response = self\n            .client\n            .post(&url)\n            .json(&GraphQlRequest { query, variables })\n            .send()\n            .await?;\n\n        if !response.status().is_success() {\n            bail!(\"GitHub GraphQL request failed: {}\", response.status());\n        }\n\n        decode_graphql_response(&response.bytes().await?)\n    }\n}\n\n/// Decode a GraphQL response body into `T`.\n///\n/// `data` is typed only once `errors` has been ruled out. GitHub reports a\n/// refused mutation as a null field *inside* `data` alongside `errors`, so\n/// typing the two together fails on that null and loses the message saying\n/// why it was refused.\nfn decode_graphql_response<T>(body: &[u8]) -> Result<T>\nwhere\n    T: for<'de> Deserialize<'de>,\n{\n    #[derive(Deserialize)]\n    struct GraphQlError {","sourceCodeStart":1246,"sourceCodeEnd":1282,"githubUrl":"https://github.com/gitbutlerapp/gitbutler/blob/caf1f223d3cfb94488c9198ad34487c6006c648f/crates/but-github/src/client.rs#L1246-L1282","documentation":"The HTTP POST to GitHub's GraphQL endpoint returned a non-2xx status. This is transport-level failure: bad credentials (401), rate limiting (403/429), or server errors (5xx). Application-level refusals arrive as 200 with an `errors` array and produce the neighboring 'GitHub GraphQL returned errors' message instead.","triggerScenarios":"Expired or revoked token (401); primary rate limit exhausted, IP allow-listing, or SAML enforcement (403); secondary rate limit with a Retry-After header (429); GitHub incident (5xx); a base_url so wrong the POST hits a server that is not the API.","commonSituations":"Long-running CLIs polling without rate-limit handling; users revoking the OAuth app; enterprise proxies; github.com outages.","solutions":["401: re-authenticate with `but config forge auth`","403/429: back off, honor Retry-After and X-RateLimit-Reset, then retry","5xx: wait and retry; check https://www.githubstatus.com","Verify the forge base URL configuration"],"exampleFix":"// before\nlet data = client.graphql_query(QUERY, &vars).await?;\n\n// after\nlet data = retryable(|attempt| async {\n    let backoff = Duration::from_secs(2u64.saturating_pow(attempt));\n    tokio::time::sleep(backoff).await;\n    client.graphql_query(QUERY, &vars).await\n}, 4).await?;","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"for attempt in 0..4 {\n    match client.graphql_query(query, &vars).await {\n        Ok(data) => break Ok(data),\n        Err(e) if e.to_string().contains(\"401\") => break Err(e.context(\"re-authenticate: but config forge auth\")),\n        Err(e) if e.to_string().contains(\"403\") || e.to_string().contains(\"429\") => {\n            tokio::time::sleep(exp_backoff(attempt)).await\n        }\n        Err(e) => break Err(e),\n    }\n}","preventionTips":["Honor Retry-After and X-RateLimit-Reset headers before retrying","Batch queries to stay under primary rate limits","Refresh tokens proactively before long-running jobs"],"tags":["github","graphql","http","rate-limit","authentication"],"backgroundTag":"github-api-request-failed","analyzedSha":"caf1f223d3cfb94488c9198ad34487c6006c648f","analyzedAt":"2026-08-20T07:55:40.983Z","schemaVersion":2},"datasetVersion":"2026-08-31T22:30:34.772Z"}