svc-infra 0.1.632__py3-none-any.whl → 0.1.634__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/api/fastapi/middleware/timeout.py +6 -2
- svc_infra/docs/admin.md +425 -0
- svc_infra/http/client.py +10 -2
- {svc_infra-0.1.632.dist-info → svc_infra-0.1.634.dist-info}/METADATA +1 -1
- {svc_infra-0.1.632.dist-info → svc_infra-0.1.634.dist-info}/RECORD +7 -6
- {svc_infra-0.1.632.dist-info → svc_infra-0.1.634.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.632.dist-info → svc_infra-0.1.634.dist-info}/entry_points.txt +0 -0
|
@@ -37,7 +37,9 @@ class HandlerTimeoutMiddleware:
|
|
|
37
37
|
|
|
38
38
|
def __init__(self, app: ASGIApp, timeout_seconds: int | None = None) -> None:
|
|
39
39
|
self.app = app
|
|
40
|
-
self.timeout_seconds =
|
|
40
|
+
self.timeout_seconds = (
|
|
41
|
+
timeout_seconds if timeout_seconds is not None else REQUEST_TIMEOUT_SECONDS
|
|
42
|
+
)
|
|
41
43
|
|
|
42
44
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
43
45
|
if scope.get("type") != "http":
|
|
@@ -77,7 +79,9 @@ class BodyReadTimeoutMiddleware:
|
|
|
77
79
|
|
|
78
80
|
def __init__(self, app: ASGIApp, timeout_seconds: int | None = None) -> None:
|
|
79
81
|
self.app = app
|
|
80
|
-
self.timeout_seconds =
|
|
82
|
+
self.timeout_seconds = (
|
|
83
|
+
timeout_seconds if timeout_seconds is not None else REQUEST_BODY_TIMEOUT_SECONDS
|
|
84
|
+
)
|
|
81
85
|
|
|
82
86
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
83
87
|
if scope.get("type") != "http":
|
svc_infra/docs/admin.md
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
# Admin Scope & Operations
|
|
2
|
+
|
|
3
|
+
This guide covers the admin subsystem: admin-only routes, permissions, impersonation, and operational guardrails.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The admin module provides:
|
|
8
|
+
- **Admin router pattern**: Role-gated endpoints under `/admin` with fine-grained permission checks
|
|
9
|
+
- **Impersonation**: Controlled user impersonation for support and debugging with full audit trails
|
|
10
|
+
- **Permission alignment**: `admin.impersonate` permission integrated with the RBAC system
|
|
11
|
+
- **Easy integration**: One-line setup via `add_admin(app, ...)`
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
### Basic Setup
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from fastapi import FastAPI
|
|
19
|
+
from svc_infra.api.fastapi.admin import add_admin
|
|
20
|
+
|
|
21
|
+
app = FastAPI()
|
|
22
|
+
|
|
23
|
+
# Mount admin endpoints with defaults
|
|
24
|
+
add_admin(app)
|
|
25
|
+
|
|
26
|
+
# Endpoints are now available:
|
|
27
|
+
# POST /admin/impersonate/start
|
|
28
|
+
# POST /admin/impersonate/stop
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Custom User Loader
|
|
32
|
+
|
|
33
|
+
If you have a custom user model or retrieval logic:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from fastapi import Request
|
|
37
|
+
|
|
38
|
+
async def my_user_getter(request: Request, user_id: str):
|
|
39
|
+
# Your custom user loading logic
|
|
40
|
+
user = await my_user_service.get_user(user_id)
|
|
41
|
+
if not user:
|
|
42
|
+
raise HTTPException(404, "user_not_found")
|
|
43
|
+
return user
|
|
44
|
+
|
|
45
|
+
add_admin(app, impersonation_user_getter=my_user_getter)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Configuration
|
|
49
|
+
|
|
50
|
+
Environment variables:
|
|
51
|
+
|
|
52
|
+
- `ADMIN_IMPERSONATION_SECRET`: Secret for signing impersonation tokens (falls back to `APP_SECRET` or `"dev-secret"`)
|
|
53
|
+
- `ADMIN_IMPERSONATION_TTL`: Token TTL in seconds (default: 900 = 15 minutes)
|
|
54
|
+
- `ADMIN_IMPERSONATION_COOKIE`: Cookie name (default: `"impersonation"`)
|
|
55
|
+
|
|
56
|
+
Function parameters:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
add_admin(
|
|
60
|
+
app,
|
|
61
|
+
base_path="/admin", # Base path for admin routes
|
|
62
|
+
enable_impersonation=True, # Enable impersonation endpoints
|
|
63
|
+
secret=None, # Override token signing secret
|
|
64
|
+
ttl_seconds=15 * 60, # Token TTL (15 minutes)
|
|
65
|
+
cookie_name="impersonation", # Cookie name
|
|
66
|
+
impersonation_user_getter=None, # Custom user loader
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Permissions & RBAC
|
|
71
|
+
|
|
72
|
+
### Admin Role
|
|
73
|
+
|
|
74
|
+
The `admin` role includes the following permissions by default:
|
|
75
|
+
|
|
76
|
+
- `user.read`, `user.write`: User management
|
|
77
|
+
- `billing.read`, `billing.write`: Billing operations
|
|
78
|
+
- `security.session.list`, `security.session.revoke`: Session management
|
|
79
|
+
- `admin.impersonate`: User impersonation
|
|
80
|
+
|
|
81
|
+
### Permission Guards
|
|
82
|
+
|
|
83
|
+
Admin endpoints use layered guards:
|
|
84
|
+
|
|
85
|
+
1. **Role gate** at router level: `RequireRoles("admin")`
|
|
86
|
+
2. **Permission gate** at endpoint level: `RequirePermission("admin.impersonate")`
|
|
87
|
+
|
|
88
|
+
This ensures both coarse-grained role membership and fine-grained permission enforcement.
|
|
89
|
+
|
|
90
|
+
### Custom Admin Routes
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from svc_infra.api.fastapi.admin import admin_router
|
|
94
|
+
from svc_infra.security.permissions import RequirePermission
|
|
95
|
+
|
|
96
|
+
# Create an admin-only router
|
|
97
|
+
router = admin_router(prefix="/admin", tags=["admin"])
|
|
98
|
+
|
|
99
|
+
@router.get("/analytics", dependencies=[RequirePermission("analytics.read")])
|
|
100
|
+
async def admin_analytics():
|
|
101
|
+
return {"data": "..."}
|
|
102
|
+
|
|
103
|
+
app.include_router(router)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Impersonation
|
|
107
|
+
|
|
108
|
+
### Use Cases
|
|
109
|
+
|
|
110
|
+
- **Customer support**: Debug issues as the affected user
|
|
111
|
+
- **Testing**: Verify permission boundaries and user-specific behavior
|
|
112
|
+
- **Compliance**: Audit access patterns under controlled conditions
|
|
113
|
+
|
|
114
|
+
### Workflow
|
|
115
|
+
|
|
116
|
+
#### 1. Start Impersonation
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
POST /admin/impersonate/start
|
|
120
|
+
Content-Type: application/json
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
"user_id": "u-12345",
|
|
124
|
+
"reason": "Investigating billing issue #789"
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Requirements:**
|
|
129
|
+
- Authenticated user must have `admin` role
|
|
130
|
+
- User must have `admin.impersonate` permission
|
|
131
|
+
- `reason` field is mandatory
|
|
132
|
+
|
|
133
|
+
**Response:** `204 No Content` with impersonation cookie set
|
|
134
|
+
|
|
135
|
+
#### 2. Make Requests as Impersonated User
|
|
136
|
+
|
|
137
|
+
All subsequent requests will be made as the target user while preserving the admin's permissions for authorization checks:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
GET /api/v1/profile
|
|
141
|
+
Cookie: impersonation=<token>
|
|
142
|
+
|
|
143
|
+
# Returns the impersonated user's profile
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Behavior:**
|
|
147
|
+
- `request.user` reflects the impersonated user
|
|
148
|
+
- `request.user.roles` inherits the actor's roles (admin maintains permissions)
|
|
149
|
+
- `principal.via` is set to `"impersonated"` for tracking
|
|
150
|
+
|
|
151
|
+
#### 3. Stop Impersonation
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
POST /admin/impersonate/stop
|
|
155
|
+
|
|
156
|
+
# Response: 204 No Content
|
|
157
|
+
# Cookie deleted, subsequent requests use original identity
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Security Guardrails
|
|
161
|
+
|
|
162
|
+
#### Short TTL
|
|
163
|
+
- Default: 15 minutes
|
|
164
|
+
- No sliding refresh: token expires after TTL regardless of activity
|
|
165
|
+
- Rationale: Minimize blast radius of compromised impersonation sessions
|
|
166
|
+
|
|
167
|
+
#### Explicit Reason
|
|
168
|
+
- Required for every impersonation start
|
|
169
|
+
- Logged in audit trail for compliance and forensics
|
|
170
|
+
|
|
171
|
+
#### Audit Trail
|
|
172
|
+
Every impersonation action is logged with:
|
|
173
|
+
- `admin.impersonation.started`: actor, target, reason, expiry
|
|
174
|
+
- `admin.impersonation.stopped`: termination reason (manual/expired)
|
|
175
|
+
|
|
176
|
+
Example log entry:
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"message": "admin.impersonation.started",
|
|
180
|
+
"actor_id": "u-admin-42",
|
|
181
|
+
"target_id": "u-12345",
|
|
182
|
+
"reason": "Investigating billing issue #789",
|
|
183
|
+
"expires_in": 900,
|
|
184
|
+
"timestamp": "2025-11-01T12:00:00Z"
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
#### Token Security
|
|
189
|
+
- HMAC-SHA256 signed tokens with nonce
|
|
190
|
+
- Includes: actor_id, target_id, issued_at, expires_at, nonce
|
|
191
|
+
- Tamper detection via signature verification
|
|
192
|
+
- Cookie attributes:
|
|
193
|
+
- `httponly=true`: No JavaScript access
|
|
194
|
+
- `samesite=lax`: CSRF protection
|
|
195
|
+
- `secure=true` in production: HTTPS only
|
|
196
|
+
|
|
197
|
+
#### Permission Preservation
|
|
198
|
+
- Impersonated requests maintain the actor's permissions
|
|
199
|
+
- Prevents privilege escalation by impersonating a higher-privileged user
|
|
200
|
+
- Target user context for data scoping, actor permissions for authorization
|
|
201
|
+
|
|
202
|
+
### Operational Recommendations
|
|
203
|
+
|
|
204
|
+
#### Development
|
|
205
|
+
```python
|
|
206
|
+
# Relaxed for local testing
|
|
207
|
+
add_admin(
|
|
208
|
+
app,
|
|
209
|
+
secret="dev-secret",
|
|
210
|
+
ttl_seconds=60 * 60, # 1 hour for convenience
|
|
211
|
+
)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
#### Production
|
|
215
|
+
```python
|
|
216
|
+
# Strict settings
|
|
217
|
+
add_admin(
|
|
218
|
+
app,
|
|
219
|
+
secret=os.environ["ADMIN_IMPERSONATION_SECRET"], # Strong secret from vault
|
|
220
|
+
ttl_seconds=15 * 60, # 15 minutes max
|
|
221
|
+
)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Best practices:**
|
|
225
|
+
- Rotate `ADMIN_IMPERSONATION_SECRET` periodically
|
|
226
|
+
- Monitor impersonation logs for anomalies
|
|
227
|
+
- Set up alerts for frequent impersonation by the same actor
|
|
228
|
+
- Consider org/tenant scoping for multi-tenant systems
|
|
229
|
+
- Document allowed impersonation reasons in your runbook
|
|
230
|
+
|
|
231
|
+
## Monitoring & Observability
|
|
232
|
+
|
|
233
|
+
### Metrics
|
|
234
|
+
|
|
235
|
+
Label admin routes with `route_class=admin` for SLO tracking:
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
from svc_infra.obs.add import add_observability
|
|
239
|
+
|
|
240
|
+
def route_classifier(path: str) -> str:
|
|
241
|
+
if path.startswith("/admin"):
|
|
242
|
+
return "admin"
|
|
243
|
+
# ... other classifications
|
|
244
|
+
return "public"
|
|
245
|
+
|
|
246
|
+
add_observability(app, route_classifier=route_classifier)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Audit Log Queries
|
|
250
|
+
|
|
251
|
+
Search for impersonation events:
|
|
252
|
+
```python
|
|
253
|
+
# Example: Query structured logs
|
|
254
|
+
logs.filter(message="admin.impersonation.started") \
|
|
255
|
+
.filter(actor_id="u-admin-42") \
|
|
256
|
+
.order_by(timestamp.desc()) \
|
|
257
|
+
.limit(100)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Compliance report:
|
|
261
|
+
```python
|
|
262
|
+
# Generate monthly impersonation summary
|
|
263
|
+
impersonations = audit_log.filter(
|
|
264
|
+
event_type__in=["admin.impersonation.started", "admin.impersonation.stopped"],
|
|
265
|
+
timestamp__gte=start_of_month,
|
|
266
|
+
)
|
|
267
|
+
report = impersonations.group_by("actor_id").agg(count="id", targets=unique("target_id"))
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Testing
|
|
271
|
+
|
|
272
|
+
### Unit Tests
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
import pytest
|
|
276
|
+
from svc_infra.api.fastapi.admin import add_admin
|
|
277
|
+
|
|
278
|
+
@pytest.mark.admin
|
|
279
|
+
def test_impersonation_requires_permission():
|
|
280
|
+
app = make_test_app()
|
|
281
|
+
add_admin(app, impersonation_user_getter=lambda req, uid: User(id=uid))
|
|
282
|
+
|
|
283
|
+
# Without admin role → 403
|
|
284
|
+
client = TestClient(app)
|
|
285
|
+
r = client.post("/admin/impersonate/start", json={"user_id": "u-2", "reason": "test"})
|
|
286
|
+
assert r.status_code == 403
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Acceptance Tests
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
@pytest.mark.acceptance
|
|
293
|
+
@pytest.mark.admin
|
|
294
|
+
def test_impersonation_lifecycle(admin_client):
|
|
295
|
+
# Start impersonation
|
|
296
|
+
r = admin_client.post(
|
|
297
|
+
"/admin/impersonate/start",
|
|
298
|
+
json={"user_id": "u-target", "reason": "acceptance test"}
|
|
299
|
+
)
|
|
300
|
+
assert r.status_code == 204
|
|
301
|
+
|
|
302
|
+
# Verify impersonated context
|
|
303
|
+
profile = admin_client.get("/api/v1/profile")
|
|
304
|
+
assert profile.json()["id"] == "u-target"
|
|
305
|
+
|
|
306
|
+
# Stop impersonation
|
|
307
|
+
r = admin_client.post("/admin/impersonate/stop")
|
|
308
|
+
assert r.status_code == 204
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Run admin tests:
|
|
312
|
+
```bash
|
|
313
|
+
pytest -m admin
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## Troubleshooting
|
|
317
|
+
|
|
318
|
+
### Impersonation Not Working
|
|
319
|
+
|
|
320
|
+
**Symptom:** Impersonation cookie set but requests still use original identity
|
|
321
|
+
|
|
322
|
+
**Check:**
|
|
323
|
+
1. Cookie is being sent: verify `Cookie: impersonation=<token>` in request headers
|
|
324
|
+
2. Token is valid: check signature and expiry
|
|
325
|
+
3. User getter succeeds: ensure `impersonation_user_getter` doesn't raise exceptions
|
|
326
|
+
4. Dependency override is active: `add_admin` registers a global override on startup
|
|
327
|
+
|
|
328
|
+
**Debug:**
|
|
329
|
+
```python
|
|
330
|
+
# Enable debug logging
|
|
331
|
+
import logging
|
|
332
|
+
logging.getLogger("svc_infra.api.fastapi.admin").setLevel(logging.DEBUG)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Permission Denied
|
|
336
|
+
|
|
337
|
+
**Symptom:** 403 when calling `/admin/impersonate/start`
|
|
338
|
+
|
|
339
|
+
**Check:**
|
|
340
|
+
1. User has `admin` role: verify `user.roles` includes `"admin"`
|
|
341
|
+
2. Permission registered: ensure `admin.impersonate` is in the permission registry
|
|
342
|
+
3. Permission assigned to role: check `PERMISSION_REGISTRY["admin"]` includes `"admin.impersonate"`
|
|
343
|
+
|
|
344
|
+
### Token Expired Too Soon
|
|
345
|
+
|
|
346
|
+
**Symptom:** Impersonation session ends before expected TTL
|
|
347
|
+
|
|
348
|
+
**Possible causes:**
|
|
349
|
+
1. TTL misconfigured: check `ADMIN_IMPERSONATION_TTL` environment variable
|
|
350
|
+
2. Server time skew: verify system clock is synchronized (NTP)
|
|
351
|
+
3. Cookie attributes: ensure `max_age` matches TTL
|
|
352
|
+
|
|
353
|
+
## Security Considerations
|
|
354
|
+
|
|
355
|
+
### Threat Model
|
|
356
|
+
|
|
357
|
+
| Threat | Mitigation |
|
|
358
|
+
|--------|-----------|
|
|
359
|
+
| Token theft (XSS) | `httponly=true` cookie prevents JavaScript access |
|
|
360
|
+
| Token theft (network) | `secure=true` requires HTTPS in production |
|
|
361
|
+
| CSRF attacks | `samesite=lax` prevents cross-site cookie sending |
|
|
362
|
+
| Privilege escalation | Actor permissions preserved during impersonation |
|
|
363
|
+
| Prolonged access | Short TTL (15 min default) with no refresh |
|
|
364
|
+
| Abuse detection | Audit logs with reason, actor, and target tracking |
|
|
365
|
+
| Insider threat | Required reason and comprehensive audit trail |
|
|
366
|
+
|
|
367
|
+
### Compliance
|
|
368
|
+
|
|
369
|
+
**SOC 2 / ISO 27001:**
|
|
370
|
+
- Audit trail requirement: ✅ All impersonation events logged
|
|
371
|
+
- Access justification: ✅ Mandatory `reason` field
|
|
372
|
+
- Time-bound access: ✅ Short TTL with no renewal
|
|
373
|
+
- Least privilege: ✅ Permission-based access control
|
|
374
|
+
|
|
375
|
+
**GDPR / Data Protection:**
|
|
376
|
+
- Lawful basis: Support/debugging under legitimate interest or contract performance
|
|
377
|
+
- Data minimization: Only necessary user context loaded
|
|
378
|
+
- Transparency: Log access for data subject access requests (DSAR)
|
|
379
|
+
- Documentation: This guide serves as basis for DPA documentation
|
|
380
|
+
|
|
381
|
+
## API Reference
|
|
382
|
+
|
|
383
|
+
### `add_admin(app, **kwargs)`
|
|
384
|
+
|
|
385
|
+
Wire admin endpoints and impersonation to a FastAPI app.
|
|
386
|
+
|
|
387
|
+
**Parameters:**
|
|
388
|
+
- `app` (FastAPI): Target application
|
|
389
|
+
- `base_path` (str): Admin router base path (default: `"/admin"`)
|
|
390
|
+
- `enable_impersonation` (bool): Enable impersonation endpoints (default: `True`)
|
|
391
|
+
- `secret` (str | None): Token signing secret (default: env `ADMIN_IMPERSONATION_SECRET`)
|
|
392
|
+
- `ttl_seconds` (int): Token TTL (default: `900` = 15 minutes)
|
|
393
|
+
- `cookie_name` (str): Cookie name (default: `"impersonation"`)
|
|
394
|
+
- `impersonation_user_getter` (Callable | None): Custom user loader `(request, user_id) -> user`
|
|
395
|
+
|
|
396
|
+
**Returns:** None (modifies app in place)
|
|
397
|
+
|
|
398
|
+
**Idempotency:** Safe to call multiple times; only wires once per app instance
|
|
399
|
+
|
|
400
|
+
### `admin_router(**kwargs)`
|
|
401
|
+
|
|
402
|
+
Create an admin-only router with role gate.
|
|
403
|
+
|
|
404
|
+
**Parameters:** Same as `APIRouter` (FastAPI)
|
|
405
|
+
|
|
406
|
+
**Returns:** APIRouter with `RequireRoles("admin")` dependency
|
|
407
|
+
|
|
408
|
+
**Example:**
|
|
409
|
+
```python
|
|
410
|
+
from svc_infra.api.fastapi.admin import admin_router
|
|
411
|
+
|
|
412
|
+
router = admin_router(prefix="/admin/reports", tags=["admin-reports"])
|
|
413
|
+
|
|
414
|
+
@router.get("/summary")
|
|
415
|
+
async def admin_summary():
|
|
416
|
+
return {"total_users": 1234}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
## Further Reading
|
|
420
|
+
|
|
421
|
+
- [ADR 0011: Admin scope and impersonation](../src/svc_infra/docs/adr/0011-admin-scope-and-impersonation.md)
|
|
422
|
+
- [Security & Auth Hardening](./security.md)
|
|
423
|
+
- [Permissions & RBAC](./security.md#permissions-and-rbac)
|
|
424
|
+
- [Audit Logging](./security.md#audit-logging)
|
|
425
|
+
- [Observability](./observability.md)
|
svc_infra/http/client.py
CHANGED
|
@@ -46,7 +46,11 @@ def new_httpx_client(
|
|
|
46
46
|
Callers can override timeout_seconds; remaining kwargs are forwarded to httpx.Client.
|
|
47
47
|
"""
|
|
48
48
|
timeout = make_timeout(timeout_seconds)
|
|
49
|
-
|
|
49
|
+
# httpx doesn't accept base_url=None; only pass if non-None
|
|
50
|
+
client_kwargs = {"timeout": timeout, "headers": headers, **kwargs}
|
|
51
|
+
if base_url is not None:
|
|
52
|
+
client_kwargs["base_url"] = base_url
|
|
53
|
+
return httpx.Client(**client_kwargs)
|
|
50
54
|
|
|
51
55
|
|
|
52
56
|
def new_async_httpx_client(
|
|
@@ -61,4 +65,8 @@ def new_async_httpx_client(
|
|
|
61
65
|
Callers can override timeout_seconds; remaining kwargs are forwarded to httpx.AsyncClient.
|
|
62
66
|
"""
|
|
63
67
|
timeout = make_timeout(timeout_seconds)
|
|
64
|
-
|
|
68
|
+
# httpx doesn't accept base_url=None; only pass if non-None
|
|
69
|
+
client_kwargs = {"timeout": timeout, "headers": headers, **kwargs}
|
|
70
|
+
if base_url is not None:
|
|
71
|
+
client_kwargs["base_url"] = base_url
|
|
72
|
+
return httpx.AsyncClient(**client_kwargs)
|
|
@@ -89,7 +89,7 @@ svc_infra/api/fastapi/middleware/ratelimit.py,sha256=Zw55_vlSVz4aqwr7gZ1P53HHZMO
|
|
|
89
89
|
svc_infra/api/fastapi/middleware/ratelimit_store.py,sha256=qJqkDi_iPrWIUZJzkhaYFcgyD1fCNDKFMa_wN53UPSQ,2796
|
|
90
90
|
svc_infra/api/fastapi/middleware/request_id.py,sha256=Iru7ypTdK_n76lwziEGDWoVF4FKS0Ps1PMASYmzK8ek,768
|
|
91
91
|
svc_infra/api/fastapi/middleware/request_size_limit.py,sha256=AcGqaB-F7Tbhg-at7ViT4Bpifst34jFneDBlUBjgo5I,1248
|
|
92
|
-
svc_infra/api/fastapi/middleware/timeout.py,sha256=
|
|
92
|
+
svc_infra/api/fastapi/middleware/timeout.py,sha256=VbbJGbJqb7LD6ZGtBrbBu_5ZlZjqkgqKuQg9NC7UrlM,5448
|
|
93
93
|
svc_infra/api/fastapi/openapi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
94
94
|
svc_infra/api/fastapi/openapi/apply.py,sha256=VAwRfcYSLCSKIpO1dp9okG1MXvkZuciU41jrSSuvUpI,1697
|
|
95
95
|
svc_infra/api/fastapi/openapi/conventions.py,sha256=e6gUsFyfEGvz3KkUimjAWMfF7_fonMJ3IoGvQZjpvfs,7171
|
|
@@ -229,6 +229,7 @@ svc_infra/db/sql/versioning.py,sha256=okZu2ad5RAFXNLXJgGpcQvZ5bc6gPjRWzwiBT0rEJJ
|
|
|
229
229
|
svc_infra/db/utils.py,sha256=aTD49VJSEu319kIWJ1uijUoP51co4lNJ3S0_tvuyGio,802
|
|
230
230
|
svc_infra/docs/acceptance-matrix.md,sha256=1J0722pdxNGVtjI5tAZA2wzzr1UiagjvPgzB8zo0Ks8,2383
|
|
231
231
|
svc_infra/docs/acceptance.md,sha256=1e9Ym4YOwnKHLfCTjx9garKlSKEnxzIYunx4cqoWhZ8,2327
|
|
232
|
+
svc_infra/docs/admin.md,sha256=_KCrfNbpfEJhAcl1kuMQrSkRLXt2VIivRalvakgn4r8,12124
|
|
232
233
|
svc_infra/docs/adr/0002-background-jobs-and-scheduling.md,sha256=iscWFmDmMFcaON3W1FePZT9aHvlBLWCoB4QE7iA5BBI,2418
|
|
233
234
|
svc_infra/docs/adr/0003-webhooks-framework.md,sha256=CFFd4RrmbTbKPr_RRuEe4zdpbpU8C6vD_DimCd702zE,1405
|
|
234
235
|
svc_infra/docs/adr/0004-tenancy-model.md,sha256=ZaJesiWqVggrRLbTXCIyyaVNiDDjl0NWPt7dSrj5SIQ,2732
|
|
@@ -264,7 +265,7 @@ svc_infra/dx/add.py,sha256=FAnLGP0BPm_q_VCEcpUwfj-b0mEse988chh9DHeS7GU,1474
|
|
|
264
265
|
svc_infra/dx/changelog.py,sha256=9SD29ZzKzbGTA6kHQXiPLtb7uueL1wrRiiLE2qMzz8o,1941
|
|
265
266
|
svc_infra/dx/checks.py,sha256=R6YqRvpKPr9zQgif4QVx2_Zl4s9YjehSkAvwlxK46lI,2267
|
|
266
267
|
svc_infra/http/__init__.py,sha256=K79-aGyq_JdsxhyxispQlnygvf9LhU0_NJQcFYlWB9I,249
|
|
267
|
-
svc_infra/http/client.py,sha256=
|
|
268
|
+
svc_infra/http/client.py,sha256=p83CLFuIKvmuV9fldNGiOk9w4OyukLIbdHIlwRZZXiU,2351
|
|
268
269
|
svc_infra/jobs/builtins/outbox_processor.py,sha256=VZoehNyjdaV_MmV74WMcbZR6z9E3VFMtZC-pxEwK0x0,1247
|
|
269
270
|
svc_infra/jobs/builtins/webhook_delivery.py,sha256=ID0V1r0OgNRlvh8zU_DQaXeZtAMQGaaTTUvqUzZG5JQ,3547
|
|
270
271
|
svc_infra/jobs/easy.py,sha256=eix-OxWeE3vdkY3GGNoYM0GAyOxc928SpiSzMkr9k0A,977
|
|
@@ -343,7 +344,7 @@ svc_infra/webhooks/fastapi.py,sha256=BCNvGNxukf6dC2a4i-6en-PrjBGV19YvCWOot5lXWsA
|
|
|
343
344
|
svc_infra/webhooks/router.py,sha256=6JvAVPMEth_xxHX-IsIOcyMgHX7g1H0OVxVXKLuMp9w,1596
|
|
344
345
|
svc_infra/webhooks/service.py,sha256=hh-rw0otc00vipZ998XaV5mHsk0IDGYqon0FnhaGr60,2229
|
|
345
346
|
svc_infra/webhooks/signing.py,sha256=NCwdZzmravUe7HVIK_uXK0qqf12FG-_MVsgPvOw6lsM,784
|
|
346
|
-
svc_infra-0.1.
|
|
347
|
-
svc_infra-0.1.
|
|
348
|
-
svc_infra-0.1.
|
|
349
|
-
svc_infra-0.1.
|
|
347
|
+
svc_infra-0.1.634.dist-info/METADATA,sha256=fZJ2ByYPa83GEs2W6niv6kDi2pstvBntZZk9PNBTp48,8748
|
|
348
|
+
svc_infra-0.1.634.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
349
|
+
svc_infra-0.1.634.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
|
|
350
|
+
svc_infra-0.1.634.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|