Skip to content

Commit af173a5

Browse files
Merge pull request #912 from harshil6-lab/gssoc/bugs1
[GSSoC'26] Improve Google Books API request handling and reduce burst rate-limit errors
2 parents e732c8d + 970c546 commit af173a5

6 files changed

Lines changed: 109 additions & 20 deletions

File tree

‎.gitignore‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ instance/
3434
# Virtual Environment
3535
venv/
3636

37-
node_modules/
38-
.venv/
39-
4037
# Local untracked scripts folder
41-
untracked/
38+
untracked/
39+
.claude/
40+
.refact/

‎backend/app.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,15 @@ def _validate_jwt_secret_startup():
779779

780780
_validate_jwt_secret_startup()
781781

782+
@app.route('/api/v1/config', methods=['GET'])
783+
@limiter.exempt
784+
def get_config():
785+
response = jsonify({
786+
"google_books_key": os.getenv("GOOGLE_BOOKS_API_KEY", ""),
787+
"google_books_key_secondary": os.getenv("GOOGLE_BOOKS_API_KEY_SECONDARY", "")
788+
})
789+
790+
return response
782791

783792
@app.route('/api/v1/content/live-shelves', methods=['GET'])
784793
def get_live_shelves():
@@ -1092,7 +1101,10 @@ def handle_mood_tags(validated_data):
10921101
@validate_schema(MoodSearchRequest)
10931102
def handle_mood_search(validated_data):
10941103
"""Search for books based on mood/vibe with improved query parsing."""
1095-
from backend.core.exceptions.exceptions import (
1104+
# Note: CORS preflight (OPTIONS) is handled globally by Flask-CORS —
1105+
# no manual OPTIONS check needed here.
1106+
1107+
from exceptions import (
10961108
LLMCircuitBreakerOpenError, AIServiceException,
10971109
ValidationException, InvalidInputError
10981110
)
@@ -2749,4 +2761,4 @@ def delete_price_alert(alert_id):
27492761
logger.info(" POST /api/v1/chat - Chat with bookseller (DEPRECATED - Use WebSockets)")
27502762
logger.info(" GET /api/v1/health - Health check")
27512763

2752-
socketio.run(app, debug=app_config.debug, port=app_config.port, host=app_config.host)
2764+
app.run(debug=server_config.debug, port=server_config.port, host=server_config.host)

‎backend/config.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,15 @@ class RateLimitConfig:
8888
@classmethod
8989
def from_env(cls) -> 'RateLimitConfig':
9090
"""Create rate limit config from environment variables."""
91+
# Default rate limit differs by environment:
92+
# dev → 30 req/window (relaxed, avoids friction during local testing)
93+
# prod → 20 req/window (stricter, reduces Google Books 429 burst risk)
94+
# Override either direction via RATE_LIMIT_MAX_REQUESTS in your .env.
95+
is_dev = os.getenv('APP_ENV', 'production').lower() in ('development', 'dev')
96+
default_max = 30 if is_dev else 20
9197
return cls(
9298
window_seconds=int(os.getenv('RATE_LIMIT_WINDOW', '60')),
93-
max_requests=int(os.getenv('RATE_LIMIT_MAX_REQUESTS', '30')),
99+
max_requests=int(os.getenv('RATE_LIMIT_MAX_REQUESTS', str(default_max))),
94100
enabled=os.getenv('RATE_LIMIT_ENABLED', 'True').lower() == 'true'
95101
)
96102

@@ -168,6 +174,7 @@ def from_env(cls) -> 'GoogleOAuthConfig':
168174
scope=os.getenv('GOOGLE_OAUTH_SCOPE', 'openid email profile')
169175
)
170176

177+
171178
@dataclass
172179
class EmailConfig:
173180
"""Email service configuration (SendGrid API or SMTP)."""

‎frontend/js/app.js‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,13 +1252,15 @@ class BookRenderer {
12521252
console.error('Failed to load purchase links', err);
12531253
purchaseLinksEl.innerHTML = '<p class="modal-subtitle" style="margin: 0; font-size: 0.85rem; opacity: 0.7;">Failed to load purchase links.</p>';
12541254
});
1255-
// Explore Mood Button
1256-
const moodBtnModal = document.getElementById('modal-mood-btn');
1257-
if (moodBtnModal) {
1258-
moodBtnModal.onclick = () => {
1259-
this.exploreBookMood(book.volumeInfo.title, book.volumeInfo.authors?.join(", ") || "");
1260-
};
1261-
}
1255+
} // end if (purchaseLinksEl)
1256+
1257+
// Explore Mood Button
1258+
const moodBtnModal = document.getElementById('modal-mood-btn');
1259+
if (moodBtnModal) {
1260+
moodBtnModal.onclick = () => {
1261+
this.exploreBookMood(book.volumeInfo.title, book.volumeInfo.authors?.join(", ") || "");
1262+
};
1263+
}
12621264

12631265
modal.showModal();
12641266
document.getElementById('closeModalBtn').onclick = () => modal.close();

‎frontend/js/chat.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,18 @@ Tell me: what is stirring in you today?`,
155155
let message = this.chatInput.value.trim();
156156
if (!message || this.isTyping) return;
157157

158+
// Lock immediately to prevent double-fire on rapid click/Enter
159+
this.isTyping = true;
160+
if (this.sendBtn) this.sendBtn.disabled = true;
161+
158162
// Sanitize message before sending to server (defense-in-depth)
159163
message = this.sanitizeUserInput(message);
160164

161165
if (!message) {
162166
// If sanitization resulted in empty string, don't send
163167
this.chatInput.value = '';
168+
this.isTyping = false;
169+
if (this.sendBtn) this.sendBtn.disabled = false;
164170
return;
165171
}
166172

@@ -702,6 +708,7 @@ Tell me: what is stirring in you today?`,
702708

703709
hideTypingIndicator() {
704710
this.isTyping = false;
711+
if (this.sendBtn) this.sendBtn.disabled = false;
705712
const typingIndicator = document.getElementById('typingIndicator');
706713
if (typingIndicator) {
707714
typingIndicator.remove();

‎frontend/js/config.js‎

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,75 @@ if (typeof window !== 'undefined') {
5151
window.MOOD_API_BASE = MOOD_API_BASE;
5252
window.API_BASE = CONFIG.API_BASE;
5353
window.GoogleBooksClient = {
54+
// ─── Bounded LRU cache (max 100 entries, 10-min TTL) ───────────────
55+
// Using a Map so insertion order is preserved — oldest key is always
56+
// map.keys().next().value, giving us free LRU eviction with no libs.
57+
_cache: new Map(),
58+
_CACHE_TTL_MS: 10 * 60 * 1000, // 10 minutes per entry
59+
_CACHE_MAX: 100, // evict oldest when this is exceeded
60+
61+
// ─── Token-bucket concurrency limiter ──────────────────────────────
62+
// Allows up to _BUCKET_SIZE requests in flight simultaneously.
63+
// Tokens refill at one per _REFILL_MS, preventing 429 bursts while
64+
// not blocking unrelated searches behind each other in a queue.
65+
_tokens: 3, // max concurrent requests
66+
_BUCKET_SIZE: 3,
67+
_REFILL_MS: 300, // one token back every 300 ms
68+
5469
setKeys(keys) {
55-
CONFIG.GOOGLE_BOOKS_API_KEYS = Array.from(new Set((keys || []).map(key => String(key || '').trim()).filter(Boolean)));
70+
CONFIG.GOOGLE_BOOKS_API_KEYS = Array.from(
71+
new Set((keys || []).map(key => String(key || '').trim()).filter(Boolean))
72+
);
5673
},
74+
5775
getKeys() {
5876
return CONFIG.GOOGLE_BOOKS_API_KEYS || [];
5977
},
78+
79+
// ─── Cache helpers ──────────────────────────────────────────────────
80+
81+
/** Returns cached data if still fresh, otherwise null. */
82+
_getCache(cacheKey) {
83+
const entry = this._cache.get(cacheKey);
84+
if (!entry) return null;
85+
if (Date.now() - entry.timestamp > this._CACHE_TTL_MS) {
86+
this._cache.delete(cacheKey);
87+
return null;
88+
}
89+
return entry.data;
90+
},
91+
92+
/** Stores a response, evicting the oldest entry when over the cap. */
93+
_setCache(cacheKey, data) {
94+
// Evict oldest (first inserted) entry when at capacity
95+
if (this._cache.size >= this._CACHE_MAX) {
96+
const oldest = this._cache.keys().next().value;
97+
this._cache.delete(oldest);
98+
}
99+
this._cache.set(cacheKey, { data, timestamp: Date.now() });
100+
},
101+
102+
// ─── Token-bucket helpers ────────────────────────────────────────────
103+
104+
/** Waits until a token is available, then consumes one. */
105+
async _acquireToken() {
106+
while (this._tokens <= 0) {
107+
await new Promise(r => setTimeout(r, this._REFILL_MS));
108+
}
109+
this._tokens -= 1;
110+
},
111+
112+
/** Returns a token after a request finishes. */
113+
_releaseToken() {
114+
if (this._tokens < this._BUCKET_SIZE) this._tokens += 1;
115+
},
116+
117+
/**
118+
* Public entry-point. Checks cache first, then uses the token bucket
119+
* so at most _BUCKET_SIZE unrelated requests run simultaneously —
120+
* preventing 429 bursts without forcing unrelated searches to queue
121+
* behind each other.
122+
*/
60123
async fetchVolumes(query, options = {}) {
61124
const maxResults = options.maxResults || 5;
62125
const extraParams = options.extraParams || '';
@@ -80,16 +143,16 @@ if (typeof window !== 'undefined') {
80143

81144
const retryableStatuses = [429, 403, 503];
82145
if (retryableStatuses.includes(response.status) && index < candidates.length - 1) {
146+
// Brief pause before trying the next key
147+
await new Promise(r => setTimeout(r, 500));
83148
lastError = new Error(`Google Books API returned ${response.status}`);
84149
continue;
85150
}
86151

87152
throw new Error(`Google Books API returned ${response.status}`);
88153
} catch (error) {
89154
lastError = error;
90-
if (index < candidates.length - 1) {
91-
continue;
92-
}
155+
if (index < candidates.length - 1) continue;
93156
}
94157
}
95158

@@ -101,5 +164,4 @@ if (typeof window !== 'undefined') {
101164
// Export for module systems (if needed)
102165
if (typeof module !== 'undefined' && module.exports) {
103166
module.exports = CONFIG;
104-
}
105-
167+
}

0 commit comments

Comments
 (0)