---
title: "Handle errors and rate limits"
description: "Catch typed errors, read the rate-limit budget and back off when a forge limits you."
canonical_url: "https://forges.link/guides/errors"
---
# Handle errors and rate limits

> Catch typed errors, read the rate-limit budget and back off when a forge limits you.

When a request fails, the call rejects with a typed error. All of these errors extend `ForgeError`, so you can catch the base class to handle everything in one place, or narrow down with `instanceof` when a specific failure needs its own response:

```ts
import { ForgeError, NotFoundError, RateLimitedError, TokenRevokedError } from 'forges'

try {
  await gh.threads.close(thread.ref)
}
catch (error) {
  if (error instanceof NotFoundError) {
    console.warn('The thread is gone, or the token cannot see it')
  }
  else if (error instanceof RateLimitedError) {
    console.warn(`Rate limited until ${error.resetAt?.toISOString()}`)
  }
  else if (error instanceof TokenRevokedError) {
    await askForNewToken()
  }
  else if (error instanceof ForgeError) {
    console.error(error.forge, error.url, error.message)
  }
  else {
    throw error
  }
}
```

If a failure came from a request, the error tells you where it happened with `forge`, `instance`, `url` and `method`. `ForgeApiError` and its subclasses go further and add the `status` of the response and a truncated `body`. You can find every error class in the [error reference](https://forges.link/reference/errors).

## Permissions

`InsufficientScopeError` means the credential is valid but lacks a scope or permission for the call. Where the forge says which one, the error carries it:

- `acceptedScopes` lists the scopes that would allow the call. Any one of them is enough.
- `grantedScopes` lists the scopes the credential holds.
- `acceptedPermissionsRaw` names the fine-grained permissions that would allow the call, as the forge wrote them, such as `issues=write`.

GitHub reports scopes in the response headers of the REST API and in the error message of the GraphQL API, and fine-grained permissions in a header. Other forges name neither, so the fields are absent there.

`ForbiddenError` is a refusal that another scope won't fix, and its `reason` says why. When the reason is `sso_required` and GitHub links the page where the user authorises the credential for the organisation, `ssoUrl` holds that link:

```ts
import { ForbiddenError, InsufficientScopeError } from 'forges'

try {
  await gh.threads.comment(thread.ref, 'Thanks!')
}
catch (error) {
  if (error instanceof InsufficientScopeError && error.acceptedScopes?.length) {
    await reauthorise({ scopes: error.acceptedScopes })
  }
  else if (error instanceof ForbiddenError && error.ssoUrl) {
    showLink('Authorise for your organisation', error.ssoUrl)
  }
  else {
    throw error
  }
}
```

## Rate limits

Forges limit how many requests you can send, and they tell you when you have to wait. If a forge rate-limits a request and asks for a pause of one minute or less with `retry-after`, the provider waits that long and tries the request once more, primary limit or secondary, so most short limits never reach your code. To see when this happens, pass `onRetry`. It is called with the `url`, the `method` and the `wait` in milliseconds, which makes it a good place to log or count retries:

```ts
const forge = github({
  auth,
  onRetry: ({ method, url, wait }) =>
    console.warn(`Retrying ${method} ${url} in ${wait} ms`),
}).create()
```

If the wait is longer than a minute, if the retry runs into a limit again, or if the forge names no wait, the call rejects with `RateLimitedError`. Every rate limit rejects with this class, whatever status the forge sent it with. Its `resetAt` property tells you when you can send again: after the wait that the forge named, or when a used-up budget resets. `secondary` is `true` when the forge applied a secondary limit, such as GitHub's limits on concurrent requests and on content creation, rather than a used-up budget. No request counter predicts a secondary limit, and without `retry-after` it has no `resetAt`.

GitHub sends a secondary limit as a `403` or a `429`, a limit on content creation sometimes as a `422`, and a GraphQL rate limit as a `200` with an error in the body. All of them reject with `RateLimitedError`.

### Read the budget

To see how much of your budget is left after a request, read `page.rateLimit` on a page, or `rateLimit` on the response of `request()`. Each reports what the forge sent along with that response. To follow the budget across every call, including calls that don't return a page, pass `onRateLimit`. It is called with the `url`, the `method` and the `rateLimit` of each response that reports one:

```ts
const forge = github({
  auth,
  onRateLimit: ({ rateLimit }) =>
    console.info(`${rateLimit.resource}: ${rateLimit.remaining} left`),
}).create()
```

To check the budget before you send a request, call `rateLimits()` on the provider. It returns the budgets that the latest responses reported, and it sends no request. Some forges keep several budgets. GitHub, for example, keeps separate budgets for `core`, `search`, `graphql` and other resources, so you get one entry for each resource that you have used, with its name in `resource`. Where a forge doesn't name its budget, `resource` is absent. The list is empty until a response reports a budget, and it leaves out a budget whose `resetAt` has passed:

```ts
for (const budget of gh.rateLimits()) {
  console.log(budget.resource, `${budget.remaining} of ${budget.limit} left until ${budget.resetAt}`)
}
```

The providers read the `x-ratelimit-*` and `ratelimit-*` headers, and the `ratelimit` and `ratelimit-policy` fields of the [IETF draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/) that Codeberg sends. Each provider keeps its own budgets, because each token has its own. If you [use several forges](https://forges.link/guides/multiple-forges), ask the provider that a ref belongs to with `forges.for(ref)?.rateLimits()`.

### Rate limits in the browser

In a browser, your code can only read the response headers that the forge exposes to other origins. At the time of writing, GitHub exposes its rate limit headers, and GitLab, Codeberg, Gitea and Gitee don't. On those forges, `page.rateLimit` is `undefined` in a browser and `rateLimits()` stays empty. This means that the budget is unknown, not that it is used up. For the same reason, a `RateLimitedError` from those forges has no `resetAt` in a browser, unless the forge exposes `retry-after`.

### Back off

When a call rejects with `RateLimitedError`, stop sending requests for a while:

- If the error has a `resetAt`, wait until then.
- Without a `resetAt`, back off exponentially. Wait a second or two, double the wait after each failure, and add some random jitter, so that clients that were limited together don't all retry at the same moment.
- For a `secondary` limit, wait at least a minute. As far as we can tell, this is what [GitHub recommends](https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api#handle-rate-limit-errors-appropriately) when it names no wait. The provider doesn't retry such a limit on its own.

```ts
import { RateLimitedError } from 'forges'

async function withBackoff<T>(send: () => Promise<T>, attempts = 5): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    try {
      return await send()
    }
    catch (error) {
      if (!(error instanceof RateLimitedError) || attempt === attempts) {
        throw error
      }
      const backoff = 1000 * 2 ** attempt + Math.random() * 1000
      const wait = error.resetAt
        ? error.resetAt.getTime() - Date.now()
        : Math.max(backoff, error.secondary ? 60_000 : 0)
      await new Promise(resolve => setTimeout(resolve, Math.max(wait, 0)))
    }
  }
}
```

You can also slow down before you reach the limit. Before each request, check the budget with `rateLimits()`, and when `remaining / limit` drops below a threshold, spread the requests that are left over the time until `resetAt`:

```ts
import type { ForgeProvider } from 'forges'

async function pace(provider: ForgeProvider, resource = 'core') {
  const budget = provider.rateLimits().find(budget => !budget.resource || budget.resource === resource)
  if (!budget?.limit || budget.remaining === undefined || !budget.resetAt || budget.remaining / budget.limit > 0.1) {
    return
  }
  const gap = (budget.resetAt.getTime() - Date.now()) / Math.max(budget.remaining, 1)
  await new Promise(resolve => setTimeout(resolve, gap))
}
```

### Poll within the budget

Polling spends a request every time, even when nothing has changed. To make polls cheaper, read the first page with a [conditional request](https://forges.link/guides/pagination#polling-with-conditional-requests): pass the page's `etag` back as `cursor: { etag }`, and the page comes back with `notModified: true` when nothing has changed. GitHub [documents](https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api#use-conditional-requests) that a `304 Not Modified` response to a conditional request with an `Authorization` header does not count against the primary rate limit, so a conditional poll of a first page there should cost next to nothing. Other forges may still count it, so watch `rateLimits()` as you poll.

## Unsupported operations

If you call an operation that the forge doesn't offer, the call rejects with `UnsupportedOperationError`. You can avoid it by checking the operation with [`can()`](https://forges.link/guides/capabilities) first.

## Warnings

Not every problem is serious enough to fail a call. A listing might skip an item that it couldn't read, or ignore a filter that the forge doesn't have. In these cases the provider finishes the call and reports the problem as a warning instead. Each warning has a stable `code`, such as `filter_unsupported` or `thread_unreadable`, so you can handle it in code.

You can read `warnings` from a page, and also from an iterable, either while you iterate or after you have finished:

```ts
const threads = gh.threads.list(repo, { sort: 'comments' })

for await (const thread of threads) {
  render(thread)
}

for (const warning of threads.warnings) {
  console.warn(warning.code, warning.message)
}
```

## Timeouts

Every provider has a `timeout`. A request that takes longer rejects with `ForgeTimeoutError`. If a request never gets a response at all, for example because of a DNS or TLS failure, it rejects with `ForgeNetworkError`.

## Retrying writes

When a write fails with `ForgeTimeoutError`, `ForgeNetworkError` or a `ForgeApiError` with a 5xx `status`, you can't tell whether the forge applied it. None of the forges that `forges` supports accepts an idempotency key on these writes, so the library can't make a retry safe for you, and providers never retry a write on their own, except after a rate limit, where the forge refused the request. Whether you can send the write again depends on what a second attempt does:

<table>
<thead>
  <tr>
    <th>
      Verb
    </th>
    
    <th>
      What a repeat does
    </th>
    
    <th>
      What to do
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        threads.close()
      </code>
      
      , <code>
        threads.reopen()
      </code>
    </td>
    
    <td>
      Leaves the same state. On Tangled, each call writes another state record
    </td>
    
    <td>
      Retry
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.update()
      </code>
    </td>
    
    <td>
      Leaves the same state. Where it makes several requests, a failure can leave the earlier fields changed, and a repeat sets the rest
    </td>
    
    <td>
      Retry
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.setLabels()
      </code>
      
      , <code>
        addLabels()
      </code>
      
      , <code>
        removeLabels()
      </code>
      
      , <code>
        setAssignees()
      </code>
      
      , <code>
        setMilestone()
      </code>
    </td>
    
    <td>
      Leaves the same state
    </td>
    
    <td>
      Retry
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.requestReview()
      </code>
    </td>
    
    <td>
      Leaves the same state. On GitLab and Bitbucket it reads the reviewers and writes them back with yours added, so a change that someone else makes in between is lost
    </td>
    
    <td>
      Retry
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.react()
      </code>
      
      , <code>
        threads.unreact()
      </code>
      
      , <code>
        threads.markDuplicate()
      </code>
    </td>
    
    <td>
      Leaves the same state
    </td>
    
    <td>
      Retry
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.editComment()
      </code>
      
      , <code>
        releases.update()
      </code>
      
      , <code>
        webhooks.update()
      </code>
      
      , <code>
        webhooks.rotateSecret()
      </code>
    </td>
    
    <td>
      Leaves the same state
    </td>
    
    <td>
      Retry
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.subscribe()
      </code>
      
      , <code>
        threads.unsubscribe()
      </code>
      
      , <code>
        threads.resolveReviewThread()
      </code>
      
      , <code>
        threads.unresolveReviewThread()
      </code>
    </td>
    
    <td>
      Leaves the same state
    </td>
    
    <td>
      Retry
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        notifications.markRead()
      </code>
      
       and the other notification writes
    </td>
    
    <td>
      Leaves the same state
    </td>
    
    <td>
      Retry
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        repos.addCollaborator()
      </code>
    </td>
    
    <td>
      Leaves the same state. GitLab rejects it once the account is a member
    </td>
    
    <td>
      Check <code>
        repos.permissionFor()
      </code>
      
       first
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.deleteComment()
      </code>
      
      , <code>
        webhooks.delete()
      </code>
    </td>
    
    <td>
      Can reject with <code>
        NotFoundError
      </code>
    </td>
    
    <td>
      Treat <code>
        NotFoundError
      </code>
      
       on a retry as done
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.merge()
      </code>
    </td>
    
    <td>
      Rejects once the pull request has merged
    </td>
    
    <td>
      Pass <code>
        sha
      </code>
      
      , and read the pull request after any failure
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.submitReview()
      </code>
    </td>
    
    <td>
      Can reject once the review is submitted
    </td>
    
    <td>
      Read it with <code>
        threads.getReview()
      </code>
      
       first
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.transfer()
      </code>
    </td>
    
    <td>
      Can reject once the thread has moved
    </td>
    
    <td>
      Look for it in the target repository first
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        repos.createLabel()
      </code>
    </td>
    
    <td>
      Rejects, because the name is taken. Tangled adds a second label with the same name
    </td>
    
    <td>
      Look for the name in <code>
        repos.labels()
      </code>
      
       first
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        releases.create()
      </code>
    </td>
    
    <td>
      Rejects for a tag that already has a release
    </td>
    
    <td>
      Read it with <code>
        releases.getByTag()
      </code>
      
       first
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        contents.commitFiles()
      </code>
    </td>
    
    <td>
      With <code>
        expectedHead
      </code>
      
      , rejects with <code>
        BranchMovedError
      </code>
      
       once the first attempt has moved the branch. With <code>
        createFrom
      </code>
      
      , rejects once the branch exists. Without either, adds a second commit
    </td>
    
    <td>
      Pass <code>
        expectedHead
      </code>
      
      , and on <code>
        BranchMovedError
      </code>
      
       read the branch to see whether its head is your commit
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.comment()
      </code>
    </td>
    
    <td>
      Posts a second comment
    </td>
    
    <td>
      Pass a <code>
        key
      </code>
      
      , and retry with <code>
        upsertComment()
      </code>
      
      , as described below
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.create()
      </code>
    </td>
    
    <td>
      Creates a second issue or discussion. Most forges reject a second open pull request from the same <code>
        head
      </code>
      
       to the same <code>
        base
      </code>
      
      , but Tangled creates one
    </td>
    
    <td>
      Look for it with <code>
        threads.list()
      </code>
      
       first
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.createReview()
      </code>
      
      , <code>
        threads.approve()
      </code>
    </td>
    
    <td>
      Adds a second review where the forge keeps reviews, as GitHub, Forgejo and Gitea do. Where an approval is a vote, sets the same vote, or rejects on GitLab
    </td>
    
    <td>
      Look for your review in <code>
        threads.reviews()
      </code>
      
       first
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.approveAndMerge()
      </code>
    </td>
    
    <td>
      Approves again, then the merge rejects
    </td>
    
    <td>
      Read the pull request, and retry with <code>
        merge()
      </code>
      
       once the approval is in
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.replyToReviewComment()
      </code>
    </td>
    
    <td>
      Posts a second reply
    </td>
    
    <td>
      Look for your reply in <code>
        threads.reviews()
      </code>
      
       first
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        checks.report()
      </code>
    </td>
    
    <td>
      Writes a second check run. A second commit status with the same name takes the place of the first, but GitLab rejects a repeat of a status that is still pending
    </td>
    
    <td>
      Retry a commit status, and on GitLab, look for it in <code>
        checks.list()
      </code>
      
       after a failure. For a check run, look for it in <code>
        checks.list()
      </code>
      
       first
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        checks.rerun()
      </code>
    </td>
    
    <td>
      Can start another run
    </td>
    
    <td>
      Read the check with <code>
        checks.get()
      </code>
      
       first
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        webhooks.create()
      </code>
    </td>
    
    <td>
      Registers a second webhook. GitHub rejects a second hook for the same URL
    </td>
    
    <td>
      Look for the URL in <code>
        webhooks.list()
      </code>
      
       first
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        webhooks.redeliver()
      </code>
    </td>
    
    <td>
      Sends the delivery again
    </td>
    
    <td>
      Deduplicate deliveries by their id where you receive them
    </td>
  </tr>
</tbody>
</table>

### Retry a comment

`upsertComment()` finds a comment by a hidden marker in its body and edits it, or leaves it as it is when it already has the same body, so a retry with the same key edits the comment that an earlier attempt posted instead of posting another. Use one key per change. Pass the key to the first attempt with `comment()`, which writes the same marker in one request. If that attempt fails, retry with `upsertComment()`, and pass the time of the first attempt as `since`, so the retry lists only the comments since then:

```ts
const key = mutation.id
const body = 'Deployed to staging'

if (mutation.attempts === 0) {
  await forge.threads.comment(ref, body, { key })
}
else {
  const since = new Date(mutation.firstAttemptAt.getTime() - 5 * 60_000)
  await forge.threads.upsertComment(ref, { key, body, since })
}
```

Take `firstAttemptAt` before you send the first attempt, and subtract a margin, because the clock of the forge can differ from yours. On GitHub, for issues and pull requests, and on Forgejo and Gitea, the forge filters the comments by `since`. Elsewhere, and for GitHub commit comments and discussions, the provider lists every comment and drops the older ones, so the retry costs a full listing.

This makes a duplicate unlikely, not impossible. A request that timed out on your side can still land on the forge after your retry has listed the comments. Wait before you retry, and longer after a timeout.

### Read before you retry

For the other writes that can repeat, read the current state after the failure, and only send the write again if it is missing. For a merge, pass the head `sha` that you reviewed, so a retry never merges a commit that arrived in between, and read the pull request after any failure:

```ts
try {
  await forge.threads.merge(ref, { method: 'squash', sha: pull.branches?.head.sha })
}
catch (error) {
  const current = await forge.threads.get(ref)
  if (current.state !== 'merged') {
    throw error
  }
}
```

There is no keyed `threads.create()`. Looking for the new thread afterwards needs a listing that shows it at once on every forge, and some forges show a new thread in their listings or their search only after a delay. Before you retry `create()`, list the recent threads of the repository with `threads.list(repo, { author, sort: 'created' })`, and compare their titles with yours.


## Sitemap

See the full [sitemap](https://forges.link/sitemap.md) for all pages.
