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 horaToda 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 pone precio familia a familia, y la consulta que pregunta qué repositorios se movieron
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 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, 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 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, 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

Sección titulada «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.
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 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í.