Skip to content

Commit 9f6c5c8

Browse files
committed
Handle network errors when fetching GitHub user
1 parent 837552a commit 9f6c5c8

File tree

8 files changed

+122
-7
lines changed

8 files changed

+122
-7
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src/lib/forge/forgeFactory.svelte.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type ForgeConfig = {
2323
baseBranch?: string;
2424
githubAuthenticated?: boolean;
2525
githubIsLoading?: boolean;
26+
githubError?: { code: string; message: string };
2627
gitlabAuthenticated?: boolean;
2728
detectedForgeProvider: ForgeProvider | undefined;
2829
forgeOverride?: ForgeName;
@@ -35,7 +36,12 @@ export class DefaultForgeFactory implements Reactive<Forge> {
3536
private _forge = $state<Forge>(this.default);
3637
private _config: any = undefined;
3738
private _determinedForgeType = $state<ForgeName>('default');
39+
private _githubError = $state<{ code: string; message: string } | undefined>(undefined);
3840
private _canSetupIntegration = $derived.by(() => {
41+
// Don't show the setup prompt if there's a network error
42+
if (this._githubError?.code === 'errors.network') {
43+
return undefined;
44+
}
3945
return isAvalilableForge(this._determinedForgeType) &&
4046
!this._forge.authenticated &&
4147
!this._forge.isLoading
@@ -78,10 +84,12 @@ export class DefaultForgeFactory implements Reactive<Forge> {
7884
baseBranch,
7985
githubAuthenticated,
8086
githubIsLoading,
87+
githubError,
8188
gitlabAuthenticated,
8289
detectedForgeProvider,
8390
forgeOverride
8491
} = config;
92+
this._githubError = githubError;
8593
if (repo && baseBranch) {
8694
this._determinedForgeType = this.determineForgeType(repo, detectedForgeProvider);
8795
this._forge = this.build({

apps/desktop/src/lib/forge/github/hooks.svelte.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ type GitHubAccess = {
5757
host: Reactive<string | undefined>;
5858
accessToken: Reactive<string | undefined>;
5959
isLoading: Reactive<boolean>;
60+
error: Reactive<{ code: string; message: string } | undefined>;
61+
isError: Reactive<boolean>;
6062
};
6163

6264
/**
@@ -79,6 +81,10 @@ export function useGitHubAccessToken(projectId: Reactive<string>): GitHubAccess
7981
return {
8082
host: reactive(() => host),
8183
accessToken: reactive(() => aceessToken),
82-
isLoading: reactive(() => ghUserResponse?.result.isLoading ?? false)
84+
isLoading: reactive(() => ghUserResponse?.result.isLoading ?? false),
85+
error: reactive(
86+
() => ghUserResponse?.result.error as { code: string; message: string } | undefined
87+
),
88+
isError: reactive(() => ghUserResponse?.result.isError ?? false)
8389
};
8490
}

apps/desktop/src/routes/[projectId]/+layout.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
baseBranch: baseBranchName,
125125
githubAuthenticated: !!githubAccessToken.accessToken.current,
126126
githubIsLoading: githubAccessToken.isLoading.current,
127+
githubError: githubAccessToken.error.current,
127128
gitlabAuthenticated: !!$gitlabConfigured,
128129
detectedForgeProvider: baseBranch?.forgeRepoInfo?.forge ?? undefined,
129130
forgeOverride: projects?.find((project) => project.id === projectId)?.forge_override

crates/but-error/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ pub enum Code {
139139
SecretKeychainNotFound,
140140
MissingLoginKeychain,
141141
GitForcePushProtection,
142+
NetworkError,
142143
}
143144

144145
impl std::fmt::Display for Code {
@@ -157,6 +158,7 @@ impl std::fmt::Display for Code {
157158
Code::SecretKeychainNotFound => "errors.secret.keychain_notfound",
158159
Code::MissingLoginKeychain => "errors.secret.missing_login_keychain",
159160
Code::GitForcePushProtection => "errors.git.force_push_protection",
161+
Code::NetworkError => "errors.network",
160162
};
161163
f.write_str(code)
162164
}

crates/but-github/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ serde.workspace = true
1818
but-settings.workspace = true
1919
but-secret.workspace = true
2020
but-forge-storage.workspace = true
21+
but-error.workspace = true
2122
anyhow.workspace = true
2223
gitbutler-user.workspace = true
2324
serde_json.workspace = true
2425
reqwest = { workspace = true, features = ["json"] }
2526
octorust = { version = "0.10.0", default-features = false }
27+
28+
[dev-dependencies]
29+
reqwest = { workspace = true, features = ["blocking"] }
30+
http = "1"

crates/but-github/src/client.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ impl GitHubClient {
2828
Ok(Self { github })
2929
}
3030

31-
pub async fn get_authenticated(&self) -> Result<AuthenticatedUser> {
31+
pub async fn get_authenticated(&self) -> Result<AuthenticatedUser, octorust::ClientError> {
3232
self.github
3333
.users()
3434
.get_authenticated()
@@ -57,7 +57,6 @@ impl GitHubClient {
5757
}
5858
}
5959
})
60-
.map_err(Into::into)
6160
}
6261

6362
pub async fn list_open_pulls(&self, owner: &str, repo: &str) -> Result<Vec<PullRequest>> {

crates/but-github/src/lib.rs

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,23 @@ pub async fn get_gh_user(
226226
let gh = account
227227
.client(&access_token)
228228
.context("Failed to create GitHub client")?;
229-
let user = gh
230-
.get_authenticated()
231-
.await
232-
.context("Failed to get authenticated user")?;
229+
let user = match gh.get_authenticated().await {
230+
Ok(user) => user,
231+
Err(client_err) => {
232+
// Check if this is a network error before converting to anyhow
233+
if is_network_error(&client_err) {
234+
return Err(anyhow::Error::from(client_err).context(
235+
but_error::Context::new_static(
236+
but_error::Code::NetworkError,
237+
"Unable to connect to GitHub.",
238+
),
239+
));
240+
}
241+
return Err(
242+
anyhow::Error::from(client_err).context("Failed to get authenticated user")
243+
);
244+
}
245+
};
233246
Ok(Some(AuthenticatedUser {
234247
access_token,
235248
login: user.login,
@@ -242,6 +255,14 @@ pub async fn get_gh_user(
242255
}
243256
}
244257

258+
/// Check if an error is a network connectivity error.
259+
///
260+
/// This includes DNS resolution failures, connection timeouts, connection refused, etc.
261+
fn is_network_error(err: &octorust::ClientError) -> bool {
262+
matches!(err, octorust::ClientError::ReqwestError(reqwest_err)
263+
if reqwest_err.is_timeout() || reqwest_err.is_connect() || reqwest_err.is_request())
264+
}
265+
245266
#[derive(Debug, Clone, PartialEq, Eq)]
246267
pub enum CredentialCheckResult {
247268
Valid,
@@ -290,3 +311,75 @@ pub async fn list_known_github_accounts(
290311
pub fn clear_all_github_tokens(storage: &but_forge_storage::controller::Controller) -> Result<()> {
291312
token::clear_all_github_accounts(storage).context("Failed to clear all GitHub tokens")
292313
}
314+
315+
#[cfg(test)]
316+
mod tests {
317+
use super::*;
318+
319+
#[test]
320+
fn test_is_network_error_with_reqwest_timeout() {
321+
// Create a reqwest error by making an actual HTTP request that will timeout
322+
let client = reqwest::blocking::Client::builder()
323+
.timeout(std::time::Duration::from_millis(1))
324+
.build()
325+
.unwrap();
326+
327+
// Try to connect to a non-routable IP address (should timeout)
328+
let result = client.get("http://192.0.2.1:80").send();
329+
330+
if let Err(reqwest_err) = result {
331+
let client_err = octorust::ClientError::ReqwestError(reqwest_err);
332+
assert!(
333+
is_network_error(&client_err),
334+
"Should detect timeout/connection errors"
335+
);
336+
} else {
337+
panic!("Expected a network error but request succeeded");
338+
}
339+
}
340+
341+
#[test]
342+
fn test_is_network_error_with_connection_error() {
343+
// Create a reqwest error and wrap it in ClientError
344+
let client = reqwest::blocking::Client::builder()
345+
.timeout(std::time::Duration::from_millis(1))
346+
.build()
347+
.unwrap();
348+
349+
let result = client.get("http://192.0.2.1:80").send();
350+
351+
if let Err(reqwest_err) = result {
352+
let client_err = octorust::ClientError::ReqwestError(reqwest_err);
353+
assert!(
354+
is_network_error(&client_err),
355+
"Should detect ClientError wrapping reqwest network errors"
356+
);
357+
} else {
358+
panic!("Expected a network error but request succeeded");
359+
}
360+
}
361+
362+
#[test]
363+
fn test_is_not_network_error_http_error() {
364+
// HTTP errors (like 401) are not network errors
365+
let client_err = octorust::ClientError::HttpError {
366+
status: http::StatusCode::UNAUTHORIZED,
367+
headers: reqwest::header::HeaderMap::new(),
368+
error: "Unauthorized".to_string(),
369+
};
370+
assert!(
371+
!is_network_error(&client_err),
372+
"Should not detect HTTP status errors as network errors"
373+
);
374+
}
375+
376+
#[test]
377+
fn test_is_not_network_error_rate_limit() {
378+
// Rate limit errors are not network errors
379+
let client_err = octorust::ClientError::RateLimited { duration: 60 };
380+
assert!(
381+
!is_network_error(&client_err),
382+
"Should not detect rate limit errors as network errors"
383+
);
384+
}
385+
}

0 commit comments

Comments
 (0)