Skip to content

Commit ec7cff2

Browse files
author
Yury Kirsanov
committed
rest_client: optional response cache
Adds an opt-in cache for the HTTP responses this module fetches. When cachedb_url is set, repeated queries are served from a cachedb backend instead of going back to the origin, in both the blocking and the asynchronous flavour. Nothing changes unless it is configured. The module had no caching of any kind, so every rest_get() was a fresh round trip even when the same worker had fetched the same URL moments earlier. For the usual OpenSIPS pattern - a per-call lookup against a rating, routing or authorization API - that round trip is the most expensive thing in the request path. The module replicates nothing itself, so the backend URL alone decides whether the cache is private to one instance or shared by a cluster. Both are useful: an in-process backend gives the cheapest possible hit and each instance warms its own cache, while a shared redis lets one node's fetch serve the whole cluster and cuts the load on the origin, at a network round trip per hit. The rule is not local versus remote, only that the backend be cheaper than the call it replaces. Freshness does not rely on the backend acting on the TTL it is handed. cachedb_mongodb, for one, takes the expires argument of its set() and ignores it, so an entry written there would never expire; each entry therefore carries an absolute expiry of its own and a hit past it is treated as a miss. That backend remains unsuitable regardless, since nothing ever reclaims the space. By default the origin decides. A response is stored only if its own Cache-Control and Expires headers permit it, and only for as long as they allow; no-store, no-cache, private, Set-Cookie and Vary are all refused, as are non-200 replies and bodies over cache_max_body. Because many internal APIs send no cache headers at all, cache_policy offers heuristic (supply a lifetime only where the origin was silent) and force (ignore origin directives), both rejected at startup without a positive cache_ttl. rest_cache_ctl() overrides a single call instead, which is usually what is wanted - loosening the policy globally reinterprets every endpoint, including the ones that were already right. The cache key is MD5(method, URL, appended header list). The headers matter: rest_append_hf() queues a process-global list consumed by the next request, so the same URL is routinely fetched with different Authorization or tenant headers and legitimately returns different bodies. A URL-only key would hand one subscriber's response to another and would pass any single-credential test. Header names are lower-cased and sorted so append order is irrelevant, and values are hashed rather than stored, so a bearer token never reaches the backend's key space. The key does not cover the request body, which is why only GET responses are stored; rest_post() and rest_put() consult the cache and report miss:method but never write to it. Each function takes an optional trailing output variable reporting where the answer came from - hit, miss, miss:<reason> or bypass - alongside the rest_cache_hits/misses/stores/skipped statistics and a computed hit rate. Both exist because a cache that stores nothing, because every origin forbids it, is otherwise indistinguishable from a working one. Where the core provides the zero-copy get_buf cachedb endpoint and the configured backend advertises it, it is used automatically; otherwise get() is. The dependency is soft, so this builds and works as is.
1 parent b043fe2 commit ec7cff2

9 files changed

Lines changed: 2131 additions & 130 deletions

File tree

modules/rest_client/README

Lines changed: 492 additions & 96 deletions
Large diffs are not rendered by default.

modules/rest_client/doc/rest_client_admin.xml

Lines changed: 541 additions & 6 deletions
Large diffs are not rendered by default.

modules/rest_client/rest_cache.c

Lines changed: 673 additions & 0 deletions
Large diffs are not rendered by default.

modules/rest_client/rest_cache.h

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
* Copyright (C) 2026 OpenSIPS Project
3+
*
4+
* This file is part of opensips, a free SIP server.
5+
*
6+
* opensips is free software; you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License as published by
8+
* the Free Software Foundation; either version 2 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* opensips is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU General Public License
17+
* along with this program; if not, write to the Free Software
18+
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
19+
*/
20+
21+
#ifndef _REST_CACHE_H_
22+
#define _REST_CACHE_H_
23+
24+
#include "../../str.h"
25+
#include "../../cachedb/cachedb.h"
26+
#include "../../statistics.h"
27+
#include <curl/curl.h>
28+
29+
/* what a lookup/store decided, reported to the script and the log */
30+
#define RCC_SRC_BYPASS "bypass"
31+
#define RCC_SRC_HIT "hit"
32+
#define RCC_SRC_MISS "miss"
33+
34+
/* module-wide policy: how far to go beyond what the origin explicitly allows */
35+
enum rcc_policy {
36+
RCC_POLICY_OFF = 0, /* never cache */
37+
RCC_POLICY_STRICT, /* only what the origin explicitly permits */
38+
RCC_POLICY_HEURISTIC, /* also cache when the origin says nothing */
39+
RCC_POLICY_FORCE, /* also cache when the origin says not to */
40+
};
41+
42+
/* per-call override, set by rest_cache_ctl() for the NEXT request only, in the
43+
* same "pending state" style the module already uses for header_list */
44+
#define RCC_CTL_UNSET (-1) /* no override: module policy applies */
45+
#define RCC_CTL_BYPASS (0) /* skip the cache entirely for this call */
46+
47+
/* upper bound on one cached response: header + ctype + body */
48+
#define RCC_MAX_VALUE (64 * 1024 + 512)
49+
50+
/*
51+
* The caching directives worth keeping out of a response. header_func() fills
52+
* this in as the headers stream past; everything else is still discarded.
53+
*/
54+
struct rcc_resp_hdrs {
55+
int have_cc; /* a Cache-Control header was seen */
56+
int cc_no_store;
57+
int cc_no_cache;
58+
int cc_private;
59+
int have_maxage; /* s-maxage or max-age was parsed */
60+
long maxage;
61+
int have_expires;
62+
time_t expires; /* absolute, from the Expires header */
63+
long age; /* Age header, 0 if absent */
64+
int have_vary;
65+
int have_setcookie;
66+
};
67+
68+
/* everything the cache needs to know about one in-flight request */
69+
struct rcc_ctx {
70+
int enabled; /* cache consulted for this call at all */
71+
int ctl_ttl; /* per-call rest_cache_ctl(), captured at */
72+
/* dispatch: -1 none, 0 bypass, >0 force */
73+
int store_ttl; /* >0 = store for this many seconds */
74+
str key; /* MD5 hex of method+url+headers */
75+
char keybuf[33];
76+
const char *src; /* provenance string, see RCC_SRC_* */
77+
char srcbuf[32]; /* holds "miss:<reason>" */
78+
struct rcc_resp_hdrs hdrs;
79+
};
80+
81+
extern char *rcc_cdb_url;
82+
extern char *rcc_policy_str;
83+
extern int rcc_cache_ttl;
84+
extern int rcc_max_body;
85+
extern enum rcc_policy rcc_policy;
86+
87+
/* module life cycle */
88+
int rcc_init(void); /* parse policy, connect the backend */
89+
void rcc_destroy(void);
90+
int rcc_enabled(void); /* a backend is configured and policy != off */
91+
92+
/* per-call override (rest_cache_ctl), cleared with the header list */
93+
extern int rcc_ctl_ttl;
94+
void rcc_ctl_reset(void);
95+
96+
/*
97+
* Build the cache key for this request. @extra_hdrs is the pending
98+
* rest_append_hf() list - it MUST take part, since the same URL can be fetched
99+
* with different Authorization/Accept headers and get different answers.
100+
*/
101+
int rcc_build_key(struct rcc_ctx *ctx, const char *method, const str *url,
102+
struct curl_slist *extra_hdrs);
103+
104+
/*
105+
* Prepare @ctx for a request: decide whether the cache applies, capture the
106+
* per-call rest_cache_ctl() override, and build the key. Returns 1 if the
107+
* cache is engaged (ctx->enabled set), 0 if this call bypasses it entirely.
108+
* The global override is cleared here, so it never leaks to a later request -
109+
* which matters for async, whose store runs long after the next call started.
110+
*/
111+
int rcc_prepare(struct rcc_ctx *ctx, const char *method, const str *url,
112+
struct curl_slist *extra_hdrs);
113+
114+
/* returns 1 and fills body/ctype/code on a hit, 0 on a miss */
115+
int rcc_lookup(struct rcc_ctx *ctx, str *body, str *ctype, int *code);
116+
117+
/* decide from policy + origin headers whether to store, and for how long */
118+
void rcc_decide(struct rcc_ctx *ctx, const char *method, int code);
119+
120+
/* store the response if rcc_decide() allowed it */
121+
void rcc_store(struct rcc_ctx *ctx, const str *body, const str *ctype, int code);
122+
123+
/* statistics - the aggregate view of what source_pv reports per call */
124+
extern stat_var *rcc_st_hits;
125+
extern stat_var *rcc_st_misses;
126+
extern stat_var *rcc_st_stores;
127+
extern stat_var *rcc_st_skipped;
128+
unsigned long rcc_stat_hit_rate(void *unused);
129+
130+
/* publish ctx->src into a script variable (no-op when source_pv is NULL) */
131+
struct sip_msg;
132+
void rcc_set_source(struct sip_msg *msg, void *source_pv, struct rcc_ctx *ctx);
133+
134+
/* one header line, as seen by header_func() */
135+
void rcc_parse_header(struct rcc_resp_hdrs *h, const char *line, int len);
136+
137+
#endif /* _REST_CACHE_H_ */

modules/rest_client/rest_cb.c

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,19 @@ size_t write_func(char *ptr, size_t size, size_t nmemb, void *body)
7979
size_t header_func(char *ptr, size_t size, size_t nmemb, void *userdata)
8080
{
8181
int len, left;
82-
str *st = (str *)userdata;
82+
struct rest_hdr_sink *sink = (struct rest_hdr_sink *)userdata;
83+
str *st = sink->ctype;
8384

8485
len = left = size * nmemb;
8586

87+
if (sink->cache)
88+
rcc_parse_header(sink->cache, ptr, len);
89+
90+
if (!st) {
91+
LM_DBG("Received: %.*s\n", len, ptr);
92+
return len;
93+
}
94+
8695
if (len > CONTENT_TYPE_HDR_LEN && *ptr == 'C' &&
8796
strncasecmp(ptr, HTTP_HDR_CONTENT_TYPE, CONTENT_TYPE_HDR_LEN) == 0) {
8897

modules/rest_client/rest_cb.h

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
#include "rest_client.h"
2929

3030
#include "../../str.h"
31+
#include "rest_cache.h"
3132
#include "../../mem/mem.h"
3233
#include "../../error.h"
3334
#include "../../dprint.h"
@@ -40,6 +41,19 @@
4041
#define MAX_HEADER_FIELD_LEN 1024 /* arbitrary */
4142

4243
size_t write_func(char *ptr, size_t size, size_t nmemb, void *userdata);
44+
/*
45+
* What header_func() writes into. Both members are optional: a script that asks
46+
* for no content-type still needs the caching headers parsed, which is why the
47+
* callback is installed unconditionally rather than only when a ctype output
48+
* variable was supplied - otherwise rest_get(url,$b) and rest_get(url,$b,$ct)
49+
* would cache differently, and the async path (which only installed it when a
50+
* ctype was present) would never see a Cache-Control header at all.
51+
*/
52+
struct rest_hdr_sink {
53+
str *ctype; /* Content-Type, if the caller wants it */
54+
struct rcc_resp_hdrs *cache; /* caching directives, if caching is on */
55+
};
56+
4357
size_t header_func(char *ptr, size_t size, size_t nmemb, void *userdata);
4458

4559
#endif /* _REST_CB_H_ */

0 commit comments

Comments
 (0)