GunSpec
Practice

Stay inside the plan

Most quota is spent re-asking for data that has not changed. What each plan allows, which calls are free, which shapes replace five requests, and how a script stops before the limit does it for you.

Two limits count requests, from the configuration the API enforces: a daily ceiling per key, and a monthly allowance for the whole account. Rate limits covers the per-minute side.

PlanRequests / month (account)Requests / day (per key)MCP calls / dayPages deep
Explorer20050205
Builder25,0005,00050025
Studio150,00010,0002,500No ceiling
Enterprise1,000,00050,00010,000No ceiling

MCP calls are a sub-cap inside the request cap, never an extra allowance: whichever is reached first refuses the call, which the MCP limits page explains. A burst of more than 10 pages in 90 seconds is also refused, so walk the catalog steadily rather than in parallel.

The plan's monthly allowance is enforced, not just reported. These are the rules, so a script can plan around them.

  • The month belongs to the account. Every key on it draws on one pool, so a second key does not double the plan, while the daily limit stays per key.
  • Only calls that were served spend it. A call a limit refused with a 429 does not, and neither does a 304, so a retry loop against a limit cannot use up a month it was refused for. A conditional request is still the free way to read your allowances, which caching explains.
  • It is a calendar month in UTC. It returns to zero at midnight UTC on the 1st, and X-Monthly-Reset says the instant. A paid plan's billing date does not move it.
  • Whichever limit is reached first refuses the call. When both are spent the 429 names MONTHLY_CAP_EXCEEDED, because that is the one that lasts longer.
  • An Explorer key's 200 requests last about 4 days at its daily limit of 50, and after that every call is refused until the 1st. That is the size of the plan, not a fault. The paid plans are for a script that runs every day, and moving up raises the allowance at once with the same key.

Two of them, and together they are usually the difference between a plan that fits and one that does not.

  • A 304 Not Modified is not counted against your daily cap or your monthly allowance. Revalidating what you already hold is free; re-downloading it is not. Caching has the mechanics.
  • A response served from your own cache never reaches us at all. Respect the Cache-Control we send and a busy page collapses to one upstream call per lifetime.
  • A webhook delivery is not a request of yours either, since we are calling you. Taking changes as deltas rather than polling for them therefore costs nothing per record, which is what mirroring the catalog is built on.

The same page, the same data, a quarter of the requests. These endpoints exist because the loop that replaces them is the one everybody writes first.

compare.ts
javascript
// Four calls, one page render.const g17 = await gunspec.firearms.get('glock-g17')const p226 = await gunspec.firearms.get('sig-sauer-p226')const m9 = await gunspec.firearms.get('beretta-92fs')const p320 = await gunspec.firearms.get('sig-sauer-p320') // One call, the same page, and the fields already lined up for a// table. Up to five ids, as one comma-separated string.const { data } = await gunspec.firearms.compare({  ids: 'glock-g17,sig-sauer-p226,beretta-92fs,sig-sauer-p320',})
  • Compare up to five records in one call rather than fetching each. The fields come back already lined up.
  • Resolve up to fifty names in one call instead of one at a time, which the resolve guide covers.
  • Ask for what you render with fields, and raise per_page rather than making more pages: one page of a hundred costs one request, five pages of twenty cost five.
  • Prefer an endpoint that already answers your question (variants, similar, attachments) over reassembling it from a list plus a loop.

The daily limit is counted per key and the monthly allowance per account, and every response says where you stand on both, so a script or a CI job never has to guess or ask. Read it from the response you already have.

HeaderWhat it tells you
X-Daily-LimitRequests this key may make per UTC day on its plan.
X-Daily-RemainingRequests this key has left today after this one: the response to the last call that will be served says 0. Pace against it.
X-Daily-ResetWhen the daily count returns to zero: the next midnight UTC, as an ISO 8601 instant.
X-Monthly-LimitRequests the plan allows per UTC month, for the whole account.
X-Monthly-RemainingRequests the account has left this month after this one. Only served calls spend it.
X-Monthly-ResetWhen the monthly allowance returns to zero: midnight UTC on the 1st, as an ISO 8601 instant.
Retry-AfterOn a 429, the seconds until the reset of the limit that refused: at most a day for the daily limit, up to 31 days for the monthly one.
read-allowance
TypeScript
Reads GUNSPEC_API_KEY from the environment.
1/3
import { GunSpec } from '@buun_group/gunspec-sdk' const gunspec = new GunSpec()

The daily headers ride every /v1 response on a plan with a daily limit, a refused call included, and the monthly ones every response to a call made with a key, so a call without a key carries none of the three monthly headers. Each figure is what is left after the call that returned it. They come from a short cache while you are well under a limit, so early on they can read high by about a minute of traffic, and they are live near a limit. Reading them costs nothing, and a conditional request that answers 304 carries them too and spends nothing. The usage endpoint is a request that counts, so keep it for reconciling, not for pacing.

A refusal is not a hiccup to retry through. Nothing is served until the limit resets, a call made before then is refused, and a free key that keeps calling is paused. A job that fails politely does five things.

  • Read X-Daily-Remaining and X-Monthly-Remaining from each response and stop when either reaches zero, rather than spend calls a limit will refuse.
  • On a 429 with DAILY_CAP_EXCEEDED or MONTHLY_CAP_EXCEEDED, stop at the first one. Do not retry in a loop: Retry-After is the seconds until the reset, error.resetsAt says the instant, and no shorter wait helps. The SDKs wait out a reset only when it is within 30 seconds (their maxRetryAfterMs option), so a call refused a second before midnight waits and succeeds, and a longer wait reaches you as an error at once.
  • Resume at the reset by scheduling the work, not by sleeping inside a request. Run the job again after X-Daily-Reset, or X-Monthly-Reset when the month is what ran out, the way a cron entry would.
  • Give each workload its own key. The daily limit is per key, so one runaway job cannot spend another's day, and the usage report shows which key it was. The month is shared by the account, so it is the plan, not the keys, that bounds the total.
  • Keep what you have already read. A response served from your cache costs nothing, and a 304 revalidation is not counted against the daily or the monthly limit, which caching explains.
stop-politely
TypeScript
Run jobs until an allowance is spent, and stop the moment it is. Returns
1/2
import { GunSpec, RateLimitError } from '@buun_group/gunspec-sdk' const gunspec = new GunSpec() // where it stopped, so a scheduler can run the rest after the reset.async function runWithinLimit(jobs, work) {  for (const [index, job] of jobs.entries()) {    try {      const res = await work(job)      const { dailyRemaining, dailyReset, monthlyRemaining, monthlyReset } = res.rateLimit

Spend one call to learn the room, then take only what fits under the smaller of the two allowances. Leave a margin, because the remaining figures can read high by about a minute of traffic early in the day, and skip cleanly rather than fail the build when there is no room. A conditional request with a stored ETag answers 304 with the same headers and spends nothing, which caching explains.

size-a-batch
TypeScript
One cheap call at the start tells the job how much room the key has.
1/2
const probe = await gunspec.firearms.list({ per_page: 1 })const { dailyRemaining, dailyReset, monthlyRemaining, monthlyReset } = probe.rateLimit

Said plainly, so it is never a surprise.

  • Every call after the first refusal is refused too. It is counted against the key's daily total, but it does not spend the month.
  • A free key that keeps calling anyway is paused. It answers 403 with KEY_ON_HOLD and a Retry-After that says when the pause ends, a repeat earns a longer pause, and a new key does not get round it.
  • Only keys are paused, never the account. Signing in, the portal and billing work as usual, so you can always sign in and change plan.
  • A paid plan is never paused. Past its monthly allowance it is refused until the reset, or until it moves to a larger plan, which raises the allowance at once.
  • The pause ends at the time in Retry-After, or at once when you move to a paid plan.
  • Stop at the first 429 and none of this happens. The errors page lists every reason, KEY_ON_HOLD included.

The daily limit applies per key, and the API refuses one key at a time, so an account-wide average hides the key that is about to start failing. The usage endpoint reports each key's requests today, what it has left and when that resets, under daily and perKey, and the account's month under currentMonth: requests served, what is left and when it returns to zero. The same numbers are on your profile. Reading it is itself a request that counts, so pace from the response headers and keep this for seeing every key at once.

usage-alert.ts
javascript
// What the account has spent this month and what every key has spent today,// from the same counters the limits are checked against. This call is a// request that counts, so use it to see everything at once and pace from the// response headers, not from here.const { data } = await gunspec.usage.get() // The month is one figure for the whole account, and counts served calls only.const month = data.currentMonthif (month.percentage > 80) {  notify(`${month.remaining} of ${month.limit} requests left this month, back at ${month.resetsAt}`)} // The day is per key. Watch the busiest key, never the account's sum: the API// refuses one key at a time, so an average hides the key about to start failing.const daily = data.dailyconst key = daily?.busiestKeyTodayif (daily && key && key.percentage > 80) {  notify(`${key.keyName} has used ${key.percentage}% of today's requests, back at ${daily.resetsAt}`)} const mcp = data.mcp?.busiestKeyTodayif (mcp && mcp.percentage > 80) {  notify(`${mcp.keyName} is at ${mcp.percentage}% of today's MCP allowance`)}

Both are legitimate. The signal tells you which one you are actually looking at.

What you seeWhat it means
Many requests, few distinct recordsCache. You are re-asking for the same rows within their lifetime.
Bursts of 429 at the top of the hourSpread the schedule and add jitter. Your cron is stampeding, not your traffic.
Steady growth in distinct records readUpgrade. This is real demand and no amount of caching removes it.
PLAN_REQUIRED on one endpointUpgrade, or do without that endpoint. A new key on the same plan gets the same answer.
Depth limit hit while walkingFilter the walk into narrower slices, or move to a plan without a page ceiling.
MONTHLY_CAP_EXCEEDED before the month endsCache first: re-reads of records that have not changed can be revalidated for nothing. If every call asks for something new, this is real demand, and a larger plan raises the allowance at once.
An Explorer key out of requests within daysThat is the plan: its month is a few days of its daily limit. A paid plan is for a script that runs every day.