caching¶
storefronts are other people's servers, and a program that polls prices should not ask them the same question twice a minute. CacheTransport keeps answers in memory for as long as you choose. every client takes a transport argument, so the cache plugs in there and the client works as before.
from supermercapy import CacheTransport, Mercadona
with Mercadona("mad3", transport=CacheTransport(ttl=600)) as mercadona:
first = mercadona.get_product("4241")
again = mercadona.get_product("4241") # answered from memory
that example makes one request. the cache is opt-in, and a client built without one caches nothing.
what it stores¶
- successful json only. the cache stores a response when its status is 2xx and its content type is
application/jsonor ends in+json, as lidl's search type does. errors, redirects, images, and html pages always reach the storefront. - gets, unless you name more.
methodsdefaults to("GET",). bonàrea reads everything through form posts and mercadona searches through a post, somethods=("GET", "POST")lets those in, keyed on the body as well as the url. - one key per question. the cache reuses an answer for a request with the same method, url, body, and request headers. the key leaves out
cookieandauthorization, because session cookies and guest tokens change under a client without changing what it asks. - no cookies on a hit. the stored copy drops
set-cookie, so an answer from memory never rewinds the client's session. the live answer that filled the cache still sets its cookies. ttlis the only freshness rule. the cache ignores the storefronts'cache-controlheaders. in october 2026 mercadona, consum, plusfresc, bonàrea, bonpreu and carrefour's storefront all answeredno-cacheorno-store, so a cache that obeyed them would reuse nothing. lidl's search allowed five minutes of reuse, and carrefour's search index allowed the same to shared caches only.- bounded. stored bodies count against
max_bytes, 32 mebibytes by default, and the least recently used answers go first. the cache passes on an answer larger than the whole budget without storing it.
clear() forgets everything. closing a client closes its transport, which forgets everything too, so give each client its own CacheTransport.
search_all() is the exception. it lends one transport to every client it builds and leaves it open, so a cache passed to it serves each store across calls, and their different urls keep the stores apart.
from supermercapy import CacheTransport, search_all
cache = CacheTransport(ttl=600)
first = search_all("leche", stores=["consum", "lidl"], transport=cache)
again = search_all("leche", stores=["consum", "lidl"], transport=cache)
cache.close()
the second call comes from memory, so that example makes two requests.
with the default methods, these requests never reach the cache:
| store | always sent |
|---|---|
| mercadona | the postcode binding, a put; search, a post |
| consum | nothing, since every read is a json get |
| plusfresc | the guest token, a post |
| bonàrea | every read, since all are posts; the language warm-up page, html |
| carrefour | product, category and home pages, html, which get_product(), get_category(), get_home() and the session warm-up read |
| lidl | campaign pages, html, which get_offers() reads; the sitemap, xml |
| bonpreu | the csrf page, html; the batch request behind get_similar() and get_related(), a put |
| dia | the session calls: the language, a patch, and the postcode, a put |
| eroski | product pages and the menu, html, which get_product(), get_categories(), the catalog walk and a client's first get_category() read |
| caprabo | the same pages as eroski |
| aldi | every algolia query behind search, listings and the catalog, a post; the overview, category and offers pages, html |
| ahorramás | every listing grid and the menu, html, which every method but get_product() reads |
| alcampo | the csrf page, html; the session move and the batch request behind get_similar() and get_related(), puts |
| condis | every storefront page, html, which get_product(), get_categories() and get_category() read, and the sign-in redirects before the first one; the scripts and the post behind from_postal_code() |
when caching is safe¶
catalog and product reads are what the cache is for: product records, category trees, category listings, searches and store lists. the same question has the same answer for a while, and a stale answer is only old.
when it is not¶
anything tied to a session or a token. the defaults keep those out. plusfresc mints its guest token with a post, and the pages that warm up carrefour's and bonàrea's sessions or carry bonpreu's csrf token are html. keep it that way, and in particular do not name POST for plusfresc. with a ttl longer than its guest token lasts, a client renewing the token would get the expired one back from memory.
dia keeps the postcode and the language in its session cookie, and the cache leaves cookies out of its key, so two dia clients with different bindings would read each other's pages from one cache. give each dia binding its own cache.
cursors that a storefront ties to a session are the other case. bonpreu's page tokens only work in the session that received them, and a page served from memory can outlive that session. page through bonpreu listings with a client that has no cache.
figures that move within the day, such as carrefour's stock counts from get_stock(), need a short ttl or no cache at all.
pick a ttl¶
the ttl is the age of the oldest answer you are willing to act on. the storefronts publish no schedule for price changes. lidl's campaigns run by the week, and its search answers carry a five-minute lifetime of their own. a few minutes to an hour is a sensible start for prices, and category trees can take longer.
the ttl also decides what the cache saves. a poller that runs every fifteen minutes with a ten-minute ttl gets nothing from memory between runs. it still saves the repeats inside one run, such as a category tree that several calls read or a product asked for twice. a ttl longer than the interval serves whole runs from memory, which is the point, but then a run can report prices up to one ttl old.
what a hit still costs¶
a hit waits for the client's pacing slot like any other request, because pacing runs before the request reaches the transport. on bonpreu that means two seconds a call even from memory. the hit does spare the storefront, which matters most on bonpreu, whose bot protection counts requests per ip address.
the supermercapy.cache logger records each hit and each stored answer at DEBUG, next to the request line the client logs for every call.
proxies and other transports¶
CacheTransport sends what it cannot answer through its own transport. httpx ignores the proxy environment variables once a client has a transport, so the default transport reads them itself: a request goes through the proxy that HTTP_PROXY, HTTPS_PROXY or ALL_PROXY names, or directly when there is none or NO_PROXY covers the host, the way a client without a cache sends it. to choose a proxy in code instead, pass a transport:
import httpx
from supermercapy import CacheTransport, Consum
network = httpx.HTTPTransport(proxy="http://127.0.0.1:3128")
with Consum(transport=CacheTransport(ttl=600, transport=network)) as consum:
categories = consum.get_categories()
the same argument takes a test double such as httpx.MockTransport.
with asyncio¶
the cache is thread safe and works the same under an AsyncClient.
a cache that outlives the process¶
CacheTransport lives in memory and goes away when the client closes. a cache on disk needs a third-party library such as hishel, and these storefronts fight its defaults. a test of hishel 1.4 in october 2026 found that the storefronts' answers forbid caching, so hishel has to be told to ignore http's caching rules. once it does, it keys a post on its url alone unless told to use the body, stores error responses unless filtered, replays the stored set-cookie on every hit, and never reuses bonàrea's answers at all, because they carry Vary: *.