Skip to content
Closed
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
All notable changes to Braid are recorded here. The project follows Semantic
Versioning once release artifacts are published.

## [0.3.1] - unreleased

### Fixed

- The GitHub installation client no longer pins the initial installation
token: it is built via octocrab's installation auth state, which caches and
auto-refreshes the token. Previously every API call began failing with 401
"Bad credentials" one hour after `serve` started (token expiry), silently
wedging mention resolution, reactions, and the write outbox until restart.
- Mention-authority resolution now backs off exponentially (2s to 60s) on
persistent GitHub errors instead of retrying every 250ms scheduler tick.

## [0.3.0] - 2026-08-31

### Added
Expand Down
12 changes: 7 additions & 5 deletions src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,16 @@ impl GitHubClient {
}
let installation =
app.apps().get_repository_installation(&repository.owner, &repository.name).await?;
let installation_id = installation.id.into_inner();
// Keep the raw id for the auto-refreshing installation client; a
// fixed personal_token client would die permanently when the token
// expires after one hour.
let installation_raw_id = installation.id;
let installation_id = installation_raw_id.into_inner();
let access: AccessTokenResponse = app
.post(&format!("/app/installations/{installation_id}/access_tokens"), None::<&()>)
.await?;
let installation_client = Octocrab::builder()
.personal_token(access.token.clone())
.build()
let installation_client = app
.installation(installation_raw_id)
.map_err(|error| GitHubError::Client(error.to_string()))?;
let repository_info = repository_identity(&installation_client, repository).await?;
let actor = viewer_identity(&installation_client).await?;
Expand Down Expand Up @@ -600,7 +603,6 @@ struct GraphQlError {

#[derive(Deserialize)]
struct AccessTokenResponse {
token: String,
expires_at: String,
#[serde(default)]
permissions: BTreeMap<String, String>,
Expand Down
40 changes: 30 additions & 10 deletions src/producer/ingress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,28 +105,48 @@ pub(crate) async fn event_worker(
) {
let mut tick = tokio::time::interval(Duration::from_millis(250));
tick.set_missed_tick_behavior(MissedTickBehavior::Delay);
// Mention-authority resolution talks to GitHub; on persistent failure
// (e.g. token expiry before the client refreshes) back off exponentially
// instead of hammering the API every tick.
let mut mention_failures: u32 = 0;
let mut mention_cooldown_until = tokio::time::Instant::now();
loop {
tokio::select! {
_ = shutdown.changed() => break,
_ = tick.tick() => {
if let Err(error) = store.advance_scheduler() {
tracing::error!(%error, "cannot advance scheduler");
}
match store.mention_candidates(16) {
Ok(candidates) => {
for candidate in candidates {
match github.repository_permission(&candidate.actor_login).await {
Ok(role) => {
let trusted = matches!(role.to_ascii_lowercase().as_str(), "maintain" | "admin");
if let Err(error) = store.resolve_mention(candidate.event_id, trusted, policy) {
tracing::error!(%error, "cannot resolve mention authority");
if tokio::time::Instant::now() >= mention_cooldown_until {
match store.mention_candidates(16) {
Ok(candidates) => {
let mut failed = false;
for candidate in candidates {
match github.repository_permission(&candidate.actor_login).await {
Ok(role) => {
let trusted = matches!(role.to_ascii_lowercase().as_str(), "maintain" | "admin");
if let Err(error) = store.resolve_mention(candidate.event_id, trusted, policy) {
tracing::error!(%error, "cannot resolve mention authority");
}
}
Err(error) => {
tracing::warn!(%error, actor = %candidate.actor_login, "mention authority remains unresolved");
failed = true;
break;
}
}
Err(error) => tracing::warn!(%error, actor = %candidate.actor_login, "mention authority remains unresolved"),
}
if failed {
mention_failures = (mention_failures + 1).min(6);
let backoff = Duration::from_secs(2u64.pow(mention_failures).min(60));
mention_cooldown_until = tokio::time::Instant::now() + backoff;
tracing::debug!(?backoff, "mention authority resolution backing off");
} else {
mention_failures = 0;
}
}
Err(error) => tracing::error!(%error, "cannot load mention candidates"),
}
Err(error) => tracing::error!(%error, "cannot load mention candidates"),
}
drain_one_write(&store, &github).await;
}
Expand Down
Loading