Blog / Data engineering

Cache API Responses in Python: TTLs and Credit Savings

To cache API responses in Python, hash the endpoint path and the normalised parameters into a key, store the JSON body in SQLite with a timestamp, and serve it again until a per-endpoint TTL expires. Only successful responses go in the cache. On a prepaid API where a successful call costs 1 credit, every cache hit is a credit you don't spend.

This tutorial builds that cache in a short script, wires it to the Google Search API, and works through the credit arithmetic on a repeated-query workload.

Why cache web data API responses at all?

Because repeated requests for the same data are the cheapest ones to remove. Each successful call to the Google Search API costs 1 credit, so ten identical calls cost 10 credits and return the same answer ten times. A cache turns the second to tenth call into a local read with no network call that costs 0 credits.

Caching also protects you from your own code. Agents loop, retry and re-ask. Dashboards refresh. Batch jobs restart after a crash and replay the first half of their input. A cache absorbs all of that without a code change at the call sites. For spend caps on agents specifically, see AI Agent API Cost Control: Cap Web Data Spend.

The idea isn't new. HTTP caching rules in RFC 9111 describe freshness lifetimes and revalidation for exactly this reason. We're building the client-side version of the same contract, with the freshness lifetime decided by you per endpoint.

What goes into the cache key?

The key must identify the request, and nothing else. For a GET API that means the endpoint path and the query parameters, normalised so that the same request always hashes to the same value. Parameter order, unset parameters and the API key shouldn't change the key; any parameter that changes the answer, such as hl, gl or page, must.

Three rules cover it:

  1. Sort parameters by name, so hl=en&query=x and query=x&hl=en match.
  2. Drop None values, so passing gl=None equals omitting gl.
  3. Exclude the X-API-Key header. Rotating a key shouldn't empty your cache, and the key must never end up in a database file.

The part people get wrong is over-normalising. Lowercasing a query or trimming it usually merges requests that return the same results, but you're asserting that, not the API. Strip surrounding whitespace, which is safe, and decide about case from your own data. The code below leaves case alone.

How do you pick a TTL for each data type?

Set the TTL to how long the answer stays useful to your application, not to how long the source stays the same. The numbers below are our own starting choices for this tutorial, not measurements of how often search results change and not Perceptlayer guarantees. The data is returned as found at the time of the call.

Endpoint Returns Starting TTL Reasoning
/v1/google-search/search-light-autocomplete Search Light Autocomplete 24 hours Short suggestion lists; a day-old list is usually fine for a research script
/v1/google-search/search-light Search Light 6 hours A few hours of lag is acceptable for most research jobs
/v1/google-search/search Search 6 hours Same choice, richer response
/v1/google-search/search-ai-overview Search Ai Overview 1 hour Shortest TTL, because we'd rather re-check generated answers sooner

Each of these endpoints costs 1 credit per successful call, so the TTL is the only thing that differs between them. Tune it by asking one question per endpoint: what is the cost of showing someone an answer that is this old? If the honest answer is "nothing", lengthen it. If it's "a wrong decision", shorten it or skip the cache, as covered further down.

How do you build the cache in Python?

Use SQLite from the standard library. It gives you a persistent file, atomic writes and no extra service, and it survives restarts, which an in-process dictionary doesn't. The sqlite3 module documentation covers everything used here. If you want a drop-in cache for requests with no custom code, the requests-cache library fits better than writing your own. This version adds what a prepaid API needs on top: credit accounting per hit and miss, and a TTL map keyed on endpoint path with a 200-only write rule. You need the requests package and your key in the API_KEY environment variable; get a free API key if you don't have one.

Step 1: Create the store

One table holds the key, the body, the time it was fetched and the credits the call cost. Storing the cost lets the cache report what it saved.

import hashlib
import json
import os
import sqlite3
import time

import requests

BASE_URL = "https://api.perceptlayer.com"
API_KEY = os.environ["API_KEY"]

TTL_SECONDS = {
    "/v1/google-search/search-light-autocomplete": 24 * 3600,
    "/v1/google-search/search-light": 6 * 3600,
    "/v1/google-search/search": 6 * 3600,
    "/v1/google-search/search-ai-overview": 3600,
}

db = sqlite3.connect("api_cache.sqlite")
db.execute(
    """CREATE TABLE IF NOT EXISTS cache (
        key TEXT PRIMARY KEY,
        body TEXT NOT NULL,
        fetched_at REAL NOT NULL,
        credits INTEGER NOT NULL
    )"""
)
stats = {"hits": 0, "misses": 0, "credits_spent": 0, "credits_saved": 0}

Step 2: Build the key

The key hashes the path and the cleaned parameters. A fixed JSON encoding with sorted keys keeps it stable.

def cache_key(path, params):
    clean = {k: str(v).strip() for k, v in params.items() if v is not None}
    raw = json.dumps([path, sorted(clean.items())], separators=(",", ":"))
    return hashlib.sha256(raw.encode()).hexdigest()

Step 3: Read, call, write

The function checks the cache first, calls the API on a miss, and stores the body only when the status is exactly 200. Errors raise, which keeps them out of the cache and costs nothing: failed calls return X-Credits-Used: 0. Any other 2xx status is returned to the caller but not cached. The fresh=True flag skips the read but still writes, so a forced refresh updates the entry.

def cached_get(path, fresh=False, **params):
    ttl = TTL_SECONDS.get(path, 0)
    key = cache_key(path, params)

    if ttl and not fresh:
        row = db.execute(
            "SELECT body, fetched_at, credits FROM cache WHERE key = ?", (key,)
        ).fetchone()
        if row and time.time() - row[1] < ttl:
            stats["hits"] += 1
            stats["credits_saved"] += row[2]
            return json.loads(row[0])

    resp = requests.get(
        BASE_URL + path,
        params={k: v for k, v in params.items() if v is not None},
        headers={"X-API-Key": API_KEY},
        timeout=30,
    )
    resp.raise_for_status()

    credits = int(resp.headers.get("X-Credits-Used", 1))
    stats["misses"] += 1
    stats["credits_spent"] += credits

    if ttl and resp.status_code == 200:
        db.execute(
            "INSERT OR REPLACE INTO cache VALUES (?, ?, ?, ?)",
            (key, resp.text, time.time(), credits),
        )
        db.commit()
    return resp.json()

Endpoints missing from TTL_SECONDS get a TTL of 0 and are never cached, which is a safe default for anything you haven't thought about yet.

Step 4: Call it

Use it exactly like the plain request. This is the recorded request from our Search endpoint page, now going through the cache.

data = cached_get(
    "/v1/google-search/search",
    query="best restaurants in Berlin",
    hl="en",
    safe="off",
)
print(data["search_information"]["organic_results_count"])
print(stats)

The response is the same JSON you'd get without the cache. A trimmed excerpt of the recorded response:

{
  "search_information": {
    "query_displayed": "best restaurants in Berlin",
    "organic_results_count": 6
  },
  "organic_results": [
    {
      "position": 1,
      "title": "The Best Restaurants in Berlin - Food Guide & Map",
      "link": "https://andershusa.com/best-restaurants-berlin-germany-guide/",
      …

Run the script twice and stats shows one miss on the first run. On the second run, in a new process, the call is a hit, because the file persists. The X-Credits-Remaining header on your first call lets you confirm that the second one didn't move your balance.

How much does caching actually save?

Savings are the number of requests you no longer send, multiplied by the credits per call. With a successful call at 1 credit, you can work it out before writing any code. The workload below is a hypothetical we chose to show the arithmetic, not a measurement.

Say a job makes 10,000 requests spread over 2,000 distinct queries, so each query is asked five times on average. The last row assumes every repeat of a query arrives within one TTL window of its first call, which makes it a best case and an upper bound on the saving. A job spread over a month with a 6-hour TTL would land between the last two rows or worse. The "40% of repeats expire" row is an assumption to show the effect of a short TTL, not an observed rate.

Scenario Calls that reach the API Credits
No cache 10,000 10,000 × 1 = 10,000
Cache, TTL too short (40% of repeats expire) 2,000 + 0.4 × 8,000 = 5,200 5,200 × 1 = 5,200
Cache, every repeat within one TTL window (best case) 2,000 2,000 × 1 = 2,000

The last row saves 8,000 credits. The best case uses 2,000 credits, which fits inside the Starter pack of 10,000 credits; the uncached run uses all of it. The credits you keep are the same whichever pack you buy, and pricing shows what each pack costs.

The middle row is the one to watch. A TTL that expires before the next repeat arrives behaves like no cache for those requests. Log the gap between repeats of the same key for a week, then set the TTL just above the typical gap. For plan sizing against the monthly free allowance of 1,000 credits, see pricing and the credits section of the docs.

When is stale data the wrong trade?

Stale data is the wrong trade whenever the change is the thing you are measuring, or whenever a decision depends on the current state. A cache that serves a six-hour-old results page is correct for a research script and wrong for a rank tracker, which exists to notice that a result moved.

Skip or shorten the cache in these cases:

  • Monitoring and alerting. If you compare today's results to yesterday's, a cache hit makes them identical and hides the change. Pass fresh=True.
  • Time-sensitive queries. News, prices and availability go out of date faster than a generic TTL assumes.
  • Agent answers shown to a user as current. Cache the lookup if you like, but pass the age along so the answer can say how old it is.
  • After a known change. If you know the underlying data moved, evict the entry instead of waiting out the TTL.

Eviction is a single DELETE. Remove one entry by its key, and purge every row older than its TTL so the file doesn't grow without bound:

def evict(path, **params):
    db.execute("DELETE FROM cache WHERE key = ?", (cache_key(path, params),))
    db.commit()


def purge_expired():
    # Keys are hashes, so rows don't record their path. The longest TTL is
    # the cutoff: anything older is expired for every endpoint.
    cutoff = time.time() - max(TTL_SECONDS.values())
    db.execute("DELETE FROM cache WHERE fetched_at < ?", (cutoff,))
    db.commit()

Rows younger than the longest TTL but older than their own endpoint's TTL stay in the file until a lookup finds them expired and INSERT OR REPLACE overwrites them, so the purge is a size control, not a freshness check.

A middle path is storing fetched_at with every hit and exposing it. A caller who sees "fetched 5 hours ago" can choose to refresh. That costs one extra field in your return value and avoids silent staleness.

What should the cache never store?

Never store errors, and never let the cache hide an error from your caller. Failures come back as an HTTP status with a JSON body of the form {"error": {"code", "message", "request_id"}}, and they cost nothing. The code above raises on any non-2xx status before reaching the write, so a 429 or a 503 can't poison the cache for six hours.

Handle those errors where the call is made. A 429 or 503 carries a Retry-After header with the seconds to wait, and an insufficient_credits (402) means the balance is empty and nothing is debited. The errors section of the docs lists every code.

One more trap: a request whose reply was lost, such as a client timeout, may have succeeded and been billed, because requests carry no idempotency key. A cache gives you a partial fix for free. If the retry happens after a successful write for the same key, the retry is a hit. But when the original reply never arrived, nothing was written, and the retry pays again. Retry Web API Calls Without Paying Twice covers that case.

Caching also pairs with paging. Each page is its own key because page is part of the parameters, so a restarted crawl skips pages it already holds. See Paginate a Web Data API in Python.

How do you check the cache is working?

Read the counters, then read the balance. stats tells you hits, misses and the credits saved per run, and a hit rate that is lower than your repeat rate means the TTL is too short or your key includes something that varies. Compare against the X-Credits-Remaining header on a miss to confirm the balance moves only when a call reaches the API.

A short report at the end of a job is enough:

total = stats["hits"] + stats["misses"]
rate = stats["hits"] / total if total else 0
print(f"hit rate {rate:.0%}, spent {stats['credits_spent']} credits, "
      f"saved {stats['credits_saved']}")

If the hit rate is near zero on a workload you know repeats, print cache_key(path, params) for two requests that should match. A stray timestamp or request ID in the parameters is the usual cause.

Next step: run your own repeated-query job through cached_get, log the gap between repeats, and set each TTL just above it. The Google Search API page has a playground for trying parameters before you hard-code them, and the data engineering category has more on running pipelines on web data.

APIs in this post

Frequently asked questions

How long should I cache Google Search API responses?

It depends on the endpoint and the use. Our starting choices in this tutorial are 24 hours for autocomplete, 6 hours for search results and 1 hour for AI overviews; they are defaults to adjust, not measured figures. Measure the gap between repeats of the same query and set the TTL just above it. Data is returned as found at the time of the call, so the TTL is your freshness decision.

Do cache hits cost credits?

No. A hit is served from your own SQLite file and never reaches the API, so nothing is debited. Only a successful call costs the credits shown on its endpoint, which is 1 credit for the Google Search endpoints. Failed calls cost nothing and return X-Credits-Used: 0, so neither a miss that errors nor a hit adds to your bill.

Should I cache error responses?

No. Errors such as rate_limited (429) or temporarily_unavailable (503) are temporary, and caching one would repeat the failure until the TTL ran out. The code here writes to the cache only after a successful response. Let the caller see the error and retry using the Retry-After header.

Is a SQLite file enough, or do I need Redis?

SQLite fits a single script or one worker, and it persists across restarts with no service to run. If several machines share one cache, or you want automatic eviction, use a shared store such as Redis. The key design and TTL logic stay the same; only the read and write calls change.

  1. Concurrent API Requests in Python: Asyncio Under Rate Limits

    A Python asyncio fetcher that paces calls to your per-second limit, honours Retry-After and prices a 20,000-call batch at 20,000 credits.

  2. Detect Schema Changes in API JSON Responses

    Infer a path-and-type schema from a recorded Google Search response (71 paths), diff each new response against it and alert on removed or retyped fields.

  3. Incremental API Data Pulls: Fetch Only New Records

    Pull only new listings: sort newest first, keep a high-water mark or a seen-ID set per query, and stop paging early. Python tested on Craigslist and eBay data.

Start with 1,000 free credits

No password to set. We email you a sign-in link, and your key works on every API.

No credit card. 1,000 free credits a month.