Ir al contenido

Límites de la 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.

CuboLímiteLo gasta
core5000 por horaToda llamada REST
graphql5000 puntos por horaLas consultas de cuenta, commits, discusiones, etiquetas e hitos, y desde el 2026-09-11 las estrellas y forks más nuevos, la lista de estrellas dadas, las búsquedas de outbound y diez de los once contadores de totals
search30 por minutoEl 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 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.

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.

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.

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:

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 para saber qué alargar primero.

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, 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.

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. 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 vuelve a preguntar en el acto. La memoria vive en el proceso, como la caché de ETags, no en el fichero de estado.

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.

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 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 un relleno que se detiene a medias ha gastado la parte cara del presupuesto y conserva solo las familias que terminó: cada una se escribe y se marca en cuanto acaba, y el resto hay que volver a lanzarlo.