# Límites de la API

GitHub tiene quince presupuestos independientes; aquí importan tres, y el freno se escala a cada uno.

Source: https://docs.jmrp.io/ghchronicle/es/api/

GitHub no tiene un límite de peticiones. Tiene quince, y cada respuesta dice a
cuál acaba de cargar en la cabecera `x-ratelimit-resource`.

## Los tres que importan aquí

| Cubo      | Límite               | Lo gasta                                                         |
| --------- | -------------------- | ---------------------------------------------------------------- |
| `core`    | 5000 por hora        | Toda llamada REST                                                |
| `graphql` | 5000 puntos por hora | Toda consulta GraphQL: las pull requests e issues, la cuenta, los commits, las discusiones, los despliegues y el resto, a los que la [tabla de coste](https://docs.jmrp.io/ghchronicle/es/api/cost/) pone precio familia a familia, y la consulta que pregunta qué repositorios se movieron |
| `search`  | 30 por minuto        | El contador de commits de `totals`, y nada más: la búsqueda GraphQL no tiene tipo COMMIT |

Dos más los carga una familia cada uno: `webhook_deliveries` (500 por minuto)
por la lista de entregas de cada hook en `settings`, y `dependency_sbom` (100
por minuto) por el SBOM en `deps`. Ninguno de los dos está entre los quince que
informa `/rate_limit`; existen solo en las cabeceras de los endpoints que los
cargan, y los dos se nombran en la [tabla de coste](https://docs.jmrp.io/ghchronicle/es/api/cost/)
donde aplican. El freno mira los tres de arriba, no el cubo que se cargó en
último lugar, lo que importa por la razón de abajo.

## El freno

`github.reserve_rate` es cuántas llamadas no se gastan nunca. Por omisión son
500. El colector detiene una familia antes que cruzar esa línea, para que lo
demás que use el mismo token siga funcionando.

```yaml
github:
  token: ${GITHUB_TOKEN}
  reserve_rate: 500
```

La reserva se escala a cada cubo: una quinta parte de su límite, o el valor
configurado, el menor de los dos.

> **El escalado es una corrección de error, no un refinamiento**
>
> Search permite treinta peticiones por minuto. Una llamada deja "29 restantes",
> y comparar eso con una reserva plana de 500 se leía como agotado, así que se
> saltaban todas las familias que quedaban de la pasada. Juzgar un cubo de
> treinta peticiones con una reserva de cinco mil deja al colector parado en
> seco.

Cuando un cubo baja de su reserva, el colector espera al reinicio que la
respuesta ya le indicó, en vez de dormir un intervalo adivinado y reintentar. En
una pasada normal la familia se salta con un aviso:

```text
level=WARN msg="rate limit reserve reached, family skipped" family=artifacts
```

Una vez está bien. En cada pasada significa que las cadencias son demasiado
rápidas para el número de repositorios; ver
[coste de una pasada](https://docs.jmrp.io/ghchronicle/es/api/cost/) para saber qué alargar primero.

## Los ETags, y por qué un 304 es gratis

Cada respuesta se cachea por su ETag. Una petición repetida envía
`If-None-Match`, y GitHub responde 304 Not Modified cuando nada ha cambiado.

**Un 304 no cuesta cuota en absoluto.** Eso no es un detalle de optimización, es
lo que hace asequibles las cadencias cortas. La mayor parte de lo que esto
recoge apenas se mueve: el desglose de lenguajes de un repositorio, sus temas,
su perfil de comunidad, la lista de workflows. Preguntarlos cada hora sería
inasumible si cada pregunta costara una llamada. Preguntar si cambiaron no
cuesta nada.

La consecuencia es que el coste medido de una pasada en la página siguiente es
una cota superior que se alcanza en la primera pasada y tras un cambio, no la
cifra en régimen estable.

Lo que la caché guarda junto a cada ETag es el valor que el colector
decodificó, codificado de nuevo, no el cuerpo que envió GitHub. Una página de
cien ejecuciones de workflow ocupa 1,4 MB de los que el colector conserva
unos cientos de bytes por ejecución, así que la caché de una pasada ocupa
alrededor de la novena parte de lo que ocuparían los cuerpos crudos, y un 304
decodifica la novena parte de los bytes. Un test ejecuta cada
colector REST dos veces contra un GitHub falso que responde 304 a la
repetición y falla en el primer punto que difiera, que es lo que mantiene la
repetición como la misma respuesta que la original. Cada entrada se indexa por
la URL y por el tipo que la decodificó, así que la única URL que dos
colectores leen de forma distinta, `GET /repos/{owner}/{repo}` (cuatro
indicadores para el descubrimiento de un repositorio nombrado en
`targets.repos` que ningún listado devolvió, cuarenta campos para la familia
repo), tiene una entrada por lector y ninguno recibe la del otro.

La caché está acotada, y la cota son 256 MB. Es un LRU, y a cada entrada se le
cobran los bytes que guarda más doscientos por la casilla del mapa, el elemento
de la lista y la estructura que los rodea, para que una caché llena de
respuestas pequeñas se contabilice por lo que de verdad cuesta y no por la
mitad. No hay clave para ella en el fichero de configuración: un despliegue cuyo
conjunto vivo no quepa la sube desde Go con `SetCacheLimit`, y
`CacheStats().Evicted` es el número que dice si ha hecho falta, porque se queda a
cero mientras quepa el conjunto vivo de una pasada.

De ahí salen dos cosas. Una cota por debajo del conjunto vivo de una pasada no es
una caché más pequeña sino ninguna caché, porque cada pasada expulsaría lo que la
siguiente está a punto de pedir. Y la cota es memoria residente en cuanto la
caché se llena, que es el número que hay que saber antes de meter el proceso en
un contenedor con límite de memoria: puede retener esa cantidad de cuerpos
decodificados además de su propia huella.

La caché sobrevive al proceso. Lo que se pidió dentro del doble de la cadencia
más larga se guarda en [un fichero junto al fichero de
estado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado), escrito como
mucho cada cinco minutos mientras el recolector corre y una vez más cuando se
para, y un reinicio lo vuelve a leer, así que su primera pasada pregunta con los
validadores que guardó la anterior. Antes de ese fichero, la primera pasada tras
un reinicio del servicio del autor hizo 130 peticiones y ninguna recibió un 304.
Cada entrada se guarda bajo un resumen de todo lo que decodifica su tipo y no
bajo el nombre del tipo, así que una actualización que cambia lo que lee un
colector vuelve a pedir enteras las URL de ese colector en lugar de responderlas
con un cuerpo guardado sin el campo nuevo.

## Un rechazo también se recuerda

Un 403 o un 404 es como GitHub dice que una función está apagada: Dependabot en
un repositorio que no lo usa, code scanning donde nunca se activó, un foro en un
repositorio con las discusiones apagadas, el perfil de comunidad de un fork, que
GitHub no sirve en absoluto (los 28 forks de la cuenta en la que se midió
respondieron 404, y los otros 39 repositorios 200). Eso no es un error, y el
colector lo convierte en "aquí no hay nada".

Lo que no es, es gratis. Un rechazo no lleva ETag, así que donde una página que
no ha cambiado no cuesta nada, una función apagada se cobra entera en cada
pasada, para siempre. Por eso un rechazo se recuerda un día, indexado por
familia, repositorio y endpoint, y las pasadas de ese día no preguntan nada.

Dos consecuencias que conviene saber:

- Enciende una función y se nota como muy tarde al día siguiente, no en la
  pasada siguiente.
- Reiniciar el proceso no vuelve a preguntar. Los rechazos se guardan con la
  caché de ETags en [el fichero junto al fichero de
  estado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado), cada uno
  hasta el final de su propio día, y borrar ese fichero es la manera de volver a
  preguntar en el acto.

Una cuota agotada también es un 403 y nunca se recuerda como tal: el cliente lo
tipa aparte precisamente para que un límite de peticiones no se lea como una
función apagada.

## Una respuesta que GitHub no pudo terminar se pregunta una vez más

Cuatro estados dicen que GitHub no terminó una respuesta: un `502` o un `504`
es la pasarela que tiene delante rindiéndose con una respuesta que no volvió a
tiempo, un `500` es la propia aplicación la que se rinde, y un `503` es un
servidor que no pudo atender la petición en ese momento. Los cuatro son
intermitentes. Medido en las pasadas de la propia cuenta del autor entre el
2026-09-11 y el 2026-09-29, de 457.098 peticiones REST GitHub respondió 50 con
un `502`, cada uno tras diez a once segundos, 2 con un `504` y 9 con un `500`.
Siete de esos `500` fueron el listado de artefactos del repositorio con la
historia de artefactos más larga, tras ocho segundos y medio: el mismo listado
lento, rindiéndose en el límite de la propia aplicación y no en el de la
pasarela. El tamaño de la página no es la causa: pedir un artefacto tardó lo
mismo que pedir cien.

Así que una petición REST que responde cualquiera de los cuatro se pregunta una
vez más, dos segundos después, antes de que el colector se entere. De los 21
`502` y `504` que se volvieron a preguntar así desde la 2.6.0, 20 obtuvieron
respuesta. Un `500` solo se ha vuelto a preguntar así a mano, una vez, y
respondió `500` otra vez; se vuelve a preguntar de todos modos, porque es el
mismo listado rindiéndose un paso más adentro, y un reintento que falla cuesta
lo mismo que el de un `502`. Esa segunda petición cumple tres cosas:

- **Se cobra.** Un `500` y un `502` cuestan cada uno una petición de `core`,
  incluso cuando se preguntan de forma condicional, donde un `304` no cuesta
  nada. Así que el reintento pasa por el freno como cualquier otra petición, y
  un presupuesto que ya está en su reserva no se gasta en él.
- **Es condicional.** Lleva el mismo `If-None-Match` que la primera, así que una
  página que no ha cambiado sigue volviendo como un `304` gratuito.
- **Es la única.** Cada intento puede retener a la familia diez segundos, y una
  petición que falla dos veces se registra y se deja para la pasada siguiente,
  como antes. Lo que el colector leyó antes se conserva en cualquier caso.

El log de un job fallido son dos peticiones: la API responde con una
redirección, que se cobra, y el almacenamiento de objetos envía el texto. Cuando
el almacenamiento responde uno de los cuatro, se vuelve a preguntar solo al
almacenamiento, en la misma URL firmada y sin freno, porque no es la API de
GitHub y no gasta presupuesto. A la API no se le pide una redirección nueva.

Una consulta GraphQL se vuelve a preguntar una vez más del mismo modo, dos
segundos después y pasando por el freno, con una respuesta excluida. GitHub
documenta su tiempo límite de GraphQL como un `502` o un `504` cuando una
consulta lleva más de diez segundos en marcha, y esa es una consulta demasiado
grande para una sola petición. El resto de los cuatro se vuelven a preguntar
tal cual, sobre la misma página: un `500`, un `503`, y un `502` o un `504` que
volvió antes de diez segundos, que no puede ser ese tiempo límite. Medido en
las mismas pasadas, de 66.821 consultas GitHub respondió 42 con un `502` tras
10,45 a 11,20 segundos, 9 con un `504` tras 11,03 a 11,23, y 3 con un `503`
tras 0,70 a 1,08 segundos. Cada una de esas tres costó un punto y era una
página de un historial de commits, y por eso un fallo rápido no se lee como
una consulta demasiado grande: el recorrido de commits tomaba entonces una
como el final de lo que podía leer, y cada una de las tres detuvo ahí su
recorrido y lo dio por bueno. Una consulta que falla dos veces es un fallo que el colector
informa, y la pasada siguiente vuelve a preguntar; también lo es una respuesta
que no es JSON y no es ese tiempo límite, que no es respuesta GraphQL alguna.

Lo que no se vuelve a preguntar:

- **Una negativa.** Cualquier `4xx`, y un `501` o un `505`, es la respuesta que
  la misma petición recibe cada vez. Un presupuesto agotado le toca esperarlo
  al freno.
- **Una petición que no recibió ninguna respuesta.** El único tipo medido fue
  un nombre que no se pudo resolver, seis veces, tres de ellas camino del
  almacenamiento del log de un job. Cada una tardó diez segundos en fallar, que
  es el resolvedor preguntando dos veces a cada servidor antes de rendirse, así
  que la resolución ya se había vuelto a preguntar. No apareció ninguna conexión
  rechazada ni reiniciada, ni una negociación TLS que agotara su tiempo, y una
  petición cuya conexión persistente se cerró antes de responderla la vuelve a
  enviar el propio cliente HTTP de Go.
- **El tiempo límite de GraphQL de GitHub.** Un `502` o un `504` a una
  consulta que estuvo diez segundos en marcha significa una consulta demasiado
  grande para responderla en ellos, con la que la misma consulta volvería a
  toparse. Los recorridos de pull requests, de commits y de pull requests en
  coautoría vuelven a preguntar sobre el mismo cursor con media página mientras
  sea mayor que diez, así que una página de cincuenta se vuelve a pedir con
  veinticinco, doce y seis, y una consulta sobre varios repositorios a la vez se
  vuelve a preguntar con la mitad de ellos, hasta uno. Lo que aún agota el
  tiempo con lo más pequeño, y cualquier otro recorrido o consulta suelta, es un
  fallo que el colector informa junto
  con las filas que leyó antes, nunca el final de lo que hay que leer: un
  relleno no anota ese repositorio como recorrido, y la pasada siguiente lo
  vuelve a leer. Hasta 2.6.3 un recorrido sin página más pequeña que pedir
  tomaba el tiempo límite por el final de sus datos y lo daba por bueno, lo que
  en la cuenta del autor detuvo doce recorridos de commits, todos con páginas
  de cincuenta; preguntados a mano, veinticinco de los mismos commits
  respondieron en menos de cinco segundos.

## El presupuesto en el log

```text
level=INFO msg="rate budget" bucket=core remaining=4354 limit=5000
```

Merece una alerta el aviso de arriba, no esta línea. Un presupuesto que baja es
normal; una familia saltada en cada pasada es un problema de configuración.

## Un relleno histórico invierte la regla

Un [relleno histórico](https://docs.jmrp.io/ghchronicle/es/how/backfill/) es la intención contraria
y lo dice. Cuando un cubo se agota espera a que la ventana se reinicie en vez de
rendirse, porque lo que se le pidió es el recorrido entero, y una familia
saltada por falta de presupuesto sería una familia que hay que volver a
recorrer. Parado a medias, pierde poco: un punto de control junto al fichero de
estado guarda su sitio, repositorio a repositorio, y [el mismo comando sigue
desde ahí](https://docs.jmrp.io/ghchronicle/es/how/backfill/#pararlo-y-retomarlo).
